Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/457-lineage-tree.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@agent-bundle/runtime": patch
---

Expose the live agent tree to routes: `(await agent()).lineage.value.tree` — `{ siblings, children, roots }` of `AgentLineagePeer` (`{ conversation, depth, parent?, startedAt, subagent?, resolution }`) — lists every other live conversation under the request's root (any depth, the root itself included for a subagent), the request's live children, and the other live root conversations (on Cursor, only those seen in the same `workspace_roots`), read at resolve time from the same lineage registry that placed the request on every surface it feeds (event routes, generated MCP tool calls correlated through a hook window or a Codex `_meta`). Nothing is invented: stopped conversations are not listed, each peer carries the registry's own `resolution` for its placement, and the tree is absent when the registry did not place the request (a standalone hook, or a `_meta` naming a thread the registry never saw start). New exported types `AgentLineageTree` and `AgentLineagePeer`; `AgentLineage.tree` is optional, so existing readers and injected `context.lineage` fixtures are unchanged. (#544)
33 changes: 33 additions & 0 deletions docs/entry-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -461,9 +461,42 @@ interface AgentLineage {
generation?: string; // Cursor generation_id, Codex turn_id, Claude prompt_id
subagent?: { id: string; type?: string; toolCallId?: string; isParallelWorker?: boolean };
resolution: 'native' | 'registry' | 'confirmed' | 'transcript' | 'inferred';
tree?: AgentLineageTree; // who else is alive, when the registry placed this request
}

interface AgentLineageTree {
siblings: readonly AgentLineagePeer[]; // every other live conversation under the same root, any depth
children: readonly AgentLineagePeer[]; // live conversations whose parent is this one
roots: readonly AgentLineagePeer[]; // other live depth-0 conversations (Cursor: same workspace_roots)
}

interface AgentLineagePeer {
conversation: string;
depth: number;
parent?: string;
startedAt: string; // when the registry saw it start
subagent?: AgentLineageSubagent;
resolution: AgentLineageResolution; // the trust level of *that* node's placement
}
```

`tree` is the other half of "where am I": the live conversations around this
one, read from the same registry that placed the request (#457). It lists
only what the registry holds — no node is invented, and a stopped node is not
listed — scoped to what the conversation may see: everything alive under its
own root (`siblings`, oldest first, the root itself included for a subagent,
so a coordinator sees the whole live tree it belongs to; filter by `parent`
for same-parent siblings), its direct `children`, and the other live `roots`
(on Cursor only roots seen in the same `workspace_roots`, the rule that scopes
child binding; a Cursor child whose conversation has not spoken yet is listed
under its `subagent_id`). Each peer carries the registry's own `resolution`
for its placement, judged exactly as on a request that node itself made. The
tree is absent when the registry did not place the request: a standalone
hook, or a Codex `_meta` that names a thread the registry never saw start,
still answers "who am I" but not "who else is here". It travels as plain
frozen data, so the Flight worker receives it unchanged and route-unit tests
inject it through the same `context.lineage` seam.

`resolution` is the trust level of `parent`/`root`/`depth`: `native` — the
host named them on this payload (a Claude/Codex root, a Codex tool call's
`_meta`); `registry` — the warm runtime's registry placed the conversation
Expand Down
12 changes: 10 additions & 2 deletions examples/host-test/src/dump.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,20 @@ const asCaptures = (records: readonly Record<string, JsonValue>[]): CaptureRecor
.filter((record) => typeof record['kind'] === 'string' && typeof record['recordedAt'] === 'string')
.map((record) => record as unknown as CaptureRecord);

/**
* A record belongs to a conversation when the host named it in an id field,
* as the session, or anywhere on the request's *own* lineage chain. The
* `tree` the lineage carries lists other live conversations (#457) and is
* deliberately not searched: a sibling's records are not this conversation's.
*/
const matchesConversation = (record: CaptureRecord, conversation: string): boolean => {
if (Object.values(record.ids).some((value) => value === conversation)) return true;
const session = (record.request as { session?: { value?: { sessionId?: string } } }).session;
if (session?.value?.sessionId === conversation) return true;
const lineage = JSON.stringify((record.request as { lineage?: unknown }).lineage ?? null);
return lineage.includes(JSON.stringify(conversation));
const lineage = (record.request as { lineage?: Observed<AgentLineage> | { readonly state?: undefined } }).lineage;
if (lineage?.state !== 'available') return false;
const { conversation: own, parent, root, subagent } = lineage.value;
return own === conversation || parent === conversation || root === conversation || subagent?.id === conversation;
};

/** The compact shape a human or an agent scans first; `full` returns the whole line. */
Expand Down
133 changes: 79 additions & 54 deletions examples/worktree-proximity/README.md
Original file line number Diff line number Diff line change
@@ -1,29 +1,35 @@
# Worktree proximity

This advanced composition reference coordinates one root task and two child
agents working in linked worktrees of the same Git repository. Application
code records topology and current intent, detects path or dependency overlap,
warns the actor handling the current event, and publishes a durable notice
addressed to the other actor. The notice ledger attempts delivery on that
actor's next admitted event. No daemon and no native directed-message API are
required.
agents working in linked worktrees of the same Git repository. The runtime's
lineage registry supplies the agent tree — who the root is, which children
are alive, who is a sibling — through `(await agent()).lineage`; application
code records only which worktree each agent works in and its current intent,
detects path or dependency overlap, warns the actor handling the current
event, and publishes a durable notice addressed to the other actor. The
notice ledger attempts delivery on that actor's next admitted event. No
daemon and no native directed-message API are required.

This example is intentionally not part of the newcomer path.

## Scenario

1. A `session/start` event records the root actor.
2. Two `agent/start` events bind native child actor IDs to distinct worktrees.
1. A `session/start` event binds the root conversation to its worktree.
2. Two `agent/start` events bind the child conversations the runtime placed
under that root to distinct worktrees.
3. `tool/before` records current path and dependency intent.
4. The pure proximity domain compares active intents from different worktrees.
4. The pure proximity domain compares active intents from different
worktrees, ignoring an intent whose agent `request.lineage.tree` no longer
lists as alive.
5. A conflict renders an `Agent.Context` warning with an `outcome: continue`
result and publishes a notice addressed to the other actor's lineage
conversation (`recipient.conversation`).
6. That actor's next event — and only that actor's, even when a sibling works
in the same worktree — admits the pending notice, changes its
evidence-backed state to `attempted`, and renders its content as context.
7. `tool/after` records an empty current intent, and `stop` marks the actor
stopped.
7. `tool/after` records an empty current intent; `agent/stop` and `stop`
release the actor's binding and whatever intent it still held (the
registry records the stop itself, so siblings stop seeing it).

The demonstration dependency convention is a `deps:` string in tool input:

Expand All @@ -39,33 +45,43 @@ repository-relative slash-separated paths by the domain module.

The application has four planes:

- **Lineage** — the agent tree is the runtime's. `(await agent()).lineage`
answers who this request is (`conversation`, `parent`, `root`, `depth`,
`resolution`) and, through `lineage.value.tree`, who else is alive:
`siblings` (every other live conversation under the same root, the root
included), `children`, and other live `roots`, each with the registry's own
`resolution` for its placement. `agentTree()` in `src/event-support.ts`
turns that into the coordinator's report; `liveConversations()` turns it
into the liveness the domain uses.
- **Providers** — `git-worktree` derives repository, branch, commit, common
Git directory, and linked-worktree identity without throwing for expected
degradation. `agent-topology` reports that its snapshot is unavailable
because providers receive no request identity or state handle.
- **Events** — canonical shared-runtime routes observe actors, bind worktrees,
record or clear intent, detect conflicts, render current-actor context, and
publish or admit notices.
- **State and notices** — one workspace-durable topology definition and the
framework notice definition share the generated runtime's SQLite driver.
Routes use only the mounted `(await agent()).state` and
`(await agent()).notices` handles; SQLite supplies cross-process durability
and idempotency without a daemon.
because providers receive no request lineage.
- **Events** — canonical shared-runtime routes bind actors to worktrees,
record or clear intent, detect conflicts, render current-actor context,
release stopped actors, and publish or admit notices.
- **State and notices** — one workspace-durable intent definition (worktree
bindings, activities, refusals) and the framework notice definition share
the generated runtime's SQLite driver. Routes use only the mounted
`(await agent()).state` and `(await agent()).notices` handles; SQLite
supplies cross-process durability and idempotency without a daemon.
- **Domain** — `src/domain/proximity.ts` contains all collision decisions and
performs no I/O.

The generated runtime owns the durable root. It mounts SQLite at
`$AGENT_BUNDLE_PLUGIN_ROOT/state`, with the generated artifact root as the
fallback anchor, and mounts topology state and the notice ledger over that
same driver. The application never opens a second store from Git identity
data; `gitWorktree.commonDir` remains identity evidence only.

The issue sketch places a snapshot at `providers.agentTopology.snapshot`, but
a provider factory receives only `{ invocation, signal }` — no request
identity, no `lineage`, and no mounted `state` handle
([agent-bundle#459](https://github.com/scriptedalchemy/agent-bundle/issues/459)) —
so this provider reports an honest unavailable result and routes read
snapshots from `(await agent()).state.read()` instead.
fallback anchor, and mounts intent state, the notice ledger, and its own
lineage journal over that same driver. The application never opens a second
store from Git identity data; `gitWorktree.commonDir` remains identity
evidence only.

The issue sketch places the agent tree at `providers.agentTopology`. The tree
is on the request (`request.lineage.tree`,
[#457](https://github.com/scriptedalchemy/agent-bundle/issues/457)), but a
provider factory receives only `{ invocation, signal }` — not the request's
`lineage` ([#459](https://github.com/scriptedalchemy/agent-bundle/issues/459)) —
so this provider reports an honest unavailable result and routes read the
tree from `(await agent()).lineage` instead.

`worktree()` in `src/api.ts` is the issue-mandated custom Promise API over the
provider value. `useWorktree()` is the hook-shaped variant for Server
Expand All @@ -82,44 +98,51 @@ registry's own resolutions (`confirmed` once the host has named every edge up
to the root; `transcript` is read from the host's own rollout file), and
`derived` is this application's fallback:

- `session/start` observes `session:<root>` as the root actor, where the root
is `(await agent()).lineage.root` when the runtime resolved a lineage and the
native `session_id` otherwise.
- `agent/start` records the child and its parent from `request.lineage`
(`conversation`, `parent`, `resolution`) when the runtime placed the start
below the root — which needs the spawning `Agent`/`Task` `tool/before` to
have passed through the same shared runtime — and from the native `agent_id`
+ `session_id` pair otherwise. Either way the child's actor id is its
lineage conversation (Claude and Codex spell it `agent_id`), which is what a
directed notice targets.
- `session/start` binds the root conversation — `(await agent()).lineage.root`
when the runtime resolved a lineage and the native `session_id` otherwise —
to its worktree, under the same id `request.lineage.tree` lists it by.
- `agent/start` binds the child named by `request.lineage` (`conversation`,
with `resolution` as the binding's provenance) when the runtime placed the
start below the root — which needs the spawning `Agent`/`Task`
`tool/before` to have passed through the same shared runtime — and the
native `agent_id` otherwise. Either way the child's actor id is its lineage
conversation (Claude and Codex spell it `agent_id`), which is what a
directed notice targets. The edge itself (parent, depth, root) is not
recorded: the registry holds it.
- Claude and Codex put the subagent's `agent_id` on every one of its hook
payloads; Cursor gives the child a fresh `conversation_id` that only the
runtime registry can bind to its `subagentStart`. A tool or stop event
therefore resolves its actor in order of evidence: the child named by
`request.lineage` (depth above zero), then the native `agent_id`, then the
active actor already bound to the event worktree, and finally the explicit
derived identity `worktree:<root>`. A carried child the topology has not
seen is observed and bound with the provenance the evidence carried; a
derived identity is never upgraded. A root-level tool envelope (lineage
actor most recently bound to the event worktree, and finally the explicit
derived identity `worktree:<root>`. A carried child not yet bound is bound
with the provenance the evidence carried; a derived identity is never
upgraded. A root-level tool envelope (lineage
depth zero) resolves through the worktree binding, so intent stays
attributed per worktree.
- `agent/start` without either lineage or native identity records an
`edgeRefused` event and renders that parent identity is unavailable. It
refuses to fabricate a topology edge.

`request.lineage` covers the request's own parent, root, depth, and subagent
record. Siblings, children, and other roots are not exposed
([#457](https://github.com/scriptedalchemy/agent-bundle/issues/457)), so this
application keeps its own topology state for the whole-tree view the
coordinator status reports.
- `agent/stop` and `stop` release the actor the envelope names (binding and
intent); an identity-less stop releases nobody.

Liveness is the registry's word, not this application's: when
`request.lineage.tree` is present, an intent held by a host-identified actor
the tree no longer lists under our root is stale and warns nobody; a derived
`worktree:<root>` actor is not a conversation and is never filtered that
way; and a lineage with no tree (a payload that proved only its own chain, a
standalone hook, or none at all) presumes nothing about who stopped. The
coordinator `status` tool reports the tree the runtime resolved for *its*
call — a client no pre-tool hook window names gets an honest
`agents: unavailable`, never a tree from another caller's point of view.

Unsupported worktree, actor, parent, state, and delivery conditions are
rendered as unavailable instead of being replaced with invented evidence.

## Framework primitive wiring

Generated route workers mount the extracted `src/state.ts` definition and the
notice ledger into every request scope. `withTopology` and `withNotices` are
notice ledger into every request scope. `withIntent` and `withNotices` are
small capability adapters over those real handles. If a surface has no
mounted handle, they return an unavailable result and the route renders that
reason as `Agent.Context`; there is no fallback write path.
Expand All @@ -140,8 +163,8 @@ conversation under one root) is available but unused here: proximity is a
message to one peer, not to the tree.
`(await agent()).notices.read()` exposes only deliveries attempted for the
current invocation, and `inbox()` only what is pending for the current
recipient. The coordinator status reports, beside the topology facts, what
became of the notices *the calling agent* published — `pending`,
recipient. The coordinator status reports, beside the agent tree, bindings,
and intents, what became of the notices *the calling agent* published — `pending`,
`attempted`, `acknowledged`, and the other ledger states, counted from
`(await agent()).notices.published()`
([#460](https://github.com/scriptedalchemy/agent-bundle/issues/460)). That
Expand All @@ -164,7 +187,9 @@ Git worktrees, and proves warning, conversation-directed delivery (the
spawning `Agent` `PreToolUse` opens the registry's spawn window and the
child's hook payloads carry its `agent_id`, as Claude's do; an event the
runtime cannot place under that child is not delivered to), replay
idempotency, and exact-revision restart durability through the generated MCP
idempotency, exact-revision restart durability, and the registry-fed agent
tree — spawned children visible to the root's `status` call, still visible
after a server restart, gone after `agent/stop` — through the generated MCP
server. Journey 8 has two honesty layers: the generated wrapper fails closed
on an identity-less `SubagentStart` for host contracts that require
`agent_id`, while the route-unit suite proves the route records a refusal for
Expand Down
18 changes: 9 additions & 9 deletions examples/worktree-proximity/src/coordination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ import {
import type { AgentNoticesHandle } from '@agent-bundle/runtime/notices';

import {
type TopologyEvents,
type TopologyState,
type IntentEvents,
type IntentState,
} from './state.js';

export type TopologyAccess =
Pick<AgentStateHandle<TopologyState, TopologyEvents>, 'dispatch' | 'read'>;
export type IntentAccess =
Pick<AgentStateHandle<IntentState, IntentEvents>, 'dispatch' | 'read'>;

export type CapabilityResult<T> =
| {
Expand All @@ -22,27 +22,27 @@ export type CapabilityResult<T> =
readonly state: 'unavailable';
};

export const withTopology = async <T>(
operation: (topology: TopologyAccess) => Promise<T>,
export const withIntent = async <T>(
operation: (intent: IntentAccess) => Promise<T>,
): Promise<CapabilityResult<T>> => {
const context = await agent();
if (context.state === undefined) {
return {
reason: 'Topology state unavailable: this request has no mounted state handle.',
reason: 'Intent state unavailable: this request has no mounted state handle.',
state: 'unavailable',
};
}
try {
return {
state: 'available',
value: await operation(
context.state as AgentStateHandle<TopologyState, TopologyEvents>,
context.state as AgentStateHandle<IntentState, IntentEvents>,
),
};
} catch (error) {
return {
reason:
`Topology state unavailable: ${error instanceof Error ? error.message : String(error)}`,
`Intent state unavailable: ${error instanceof Error ? error.message : String(error)}`,
state: 'unavailable',
};
}
Expand Down
Loading
Loading