diff --git a/docs/architecture.md b/docs/architecture.md index e28a384eb3..9a81b1c6d3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -48,7 +48,7 @@ Dependency rule: plugins depend on interface packages, never on `dsh-agent-loop` | `ctx.sessionPersistence` | `SessionPersistence` (abstract) | dsh-session-persistence | durable persistence seam: create/append/load/list sessions | | `ctx.systemPrompt` | `SystemPrompt` | dsh-system-prompt | ordered sections + tool schemas → `assemble()` | | `ctx.tools` | `ToolRegistry` | dsh-tools | tool definitions; `execute()` through waterfall | -| `ctx.agents` | `AgentRegistry` | dsh-agent | live `Agent` handles + the create/resume factory seam | +| `ctx.agents` | `AgentRegistry` | dsh-agent | live `Agent` handles + the create/resume factory seam (returns an `AgentHandle` = `{ agent, dispose() }` for owned per-agent teardown) | | `ctx.agentLoop` | `AgentLoop` | dsh-agent-loop | creates `ReactLoopAgent`s and drives their loops | | `ctx.bash` | `BashExecutor` (abstract) | dsh-bash | bash execution seam: foreground runs + background tasks | diff --git a/docs/rfc/proposed/2026-06-14-acp-multi-session.md b/docs/rfc/proposed/2026-06-14-acp-multi-session.md index 8cc2237eff..e4d7bbbe41 100644 --- a/docs/rfc/proposed/2026-06-14-acp-multi-session.md +++ b/docs/rfc/proposed/2026-06-14-acp-multi-session.md @@ -3,7 +3,7 @@ Status: proposed -> **Implementation status:** the multi-session bridge (steps 1, 3, 4) and the bash task-ownership isolation are implemented in `packages/acp` + `packages/tool-bash`. **Per-session *permission* ownership is deferred** — it depends on [the ACP support permission gate](2026-06-14-acp-agent-client-protocol.md) (`TODO(rfc010-permission-gate)`), which is itself deferred; the `agent→sessionId` reverse map the gate will route through is in place. Step 2's "real per-session disposer scope" is also deferred (`TODO(rfc010-agent-disposal)`): the bridge demuxes via id-keyed maps and global `ctx.on` listeners (correct and leak-free — disposal drains every session in parallel to quiescence), and a per-agent disposer seam is the follow-up. Status stays `proposed` until per-session permission ownership lands. +> **Implementation status:** the multi-session bridge (steps 1, 3, 4) and the bash task-ownership isolation are implemented in `packages/acp` + `packages/tool-bash`. **Per-session *permission* ownership is deferred** — it depends on [the ACP support permission gate](2026-06-14-acp-agent-client-protocol.md) (`TODO(rfc010-permission-gate)`), which is itself deferred; the `agent→sessionId` reverse map the gate will route through is in place. Step 2's per-session disposer scope is now implemented (see [agent lifecycle & ownership seams](2026-06-18-agent-lifecycle-and-ownership-seams.md)): the factory returns a per-agent `AgentHandle` whose `dispose()` stops the loop, awaits quiescence, unregisters the agent, and removes its session, so a bare client disconnect leaves no registered agent or session-store entry. Status stays `proposed` until per-session permission ownership lands. ## Problem diff --git a/examples/coding-agent/tests/resume.e2e.ts b/examples/coding-agent/tests/resume.e2e.ts index b720efa8c9..cf2d910138 100644 --- a/examples/coding-agent/tests/resume.e2e.ts +++ b/examples/coding-agent/tests/resume.e2e.ts @@ -41,7 +41,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses agentId: 'resume-1', sessionId: SESSION_ID, agentOptions: { model: 'deepseek-v4-flash', systemPrompt: SYSTEM_PROMPT }, - }) as ReactLoopAgent + }).agent as ReactLoopAgent first.send([{ type: 'text', text: `Remember this code for later: ${SECRET}. Just acknowledge it.` }]) await waitForIdle(ctx, first) await ctx.fiber.dispose() @@ -51,11 +51,11 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses // session. The loaded event log seeds the live session, so the model sees // run 1's exchange as conversation history. ctx = await codingHarness(process.cwd(), root) - const resumed = await ctx.agents.resume({ + const resumed = (await ctx.agents.resume({ agentId: 'resume-2', resumeSessionId: SESSION_ID, agentOptions: { model: 'deepseek-v4-flash', systemPrompt: SYSTEM_PROMPT }, - }) as ReactLoopAgent + })).agent as ReactLoopAgent expect(resumed.session.id).toBe(SESSION_ID) // The prior user turn is in the rehydrated log before the model is asked. expect(JSON.stringify(resumed.session.deriveMessages())).toContain(SECRET) diff --git a/packages/acp/README.md b/packages/acp/README.md index 0bacaeef07..5cdfa3a202 100644 --- a/packages/acp/README.md +++ b/packages/acp/README.md @@ -61,13 +61,11 @@ A `session/prompt` resolves (or rejects) exactly once, keyed off the canonical s ## Disposal & disconnect -Teardown reaches quiescence: for EVERY live session settle any pending prompt as `cancelled`, `agent.abort()`, then `await agent.whenIdle()` — the interface-level quiescence signal (NOT `agent/status('disposed')`, which fires before the driver exits). The agents drain in parallel. The same teardown runs on a **client disconnect** (`conn.closed` resolves when the editor quits / the transport EOFs), so a vanished client never leaves an orphaned running agent whose `session/update` writes are silently swallowed. The two paths are idempotent and memoized (the first clears the `sessions` map; a second caller awaits the same teardown promise). +Teardown reaches quiescence: for EVERY live session settle any pending prompt as `cancelled`, then run that session's [`AgentHandle`](../agent/README.md) `dispose()` — which stops the loop with the queue-aware `cancel()`, `await`s the loop's exit (the final `turn/end` + `session/flush` are captured while the session is still attached), unregisters the agent, and removes its session from the store. The per-session disposes run in parallel. The same teardown runs on a **client disconnect** (`conn.closed` resolves when the editor quits / the transport EOFs), so a vanished client never leaves an orphaned running — or idled-but-still-registered — agent whose `session/update` writes are silently swallowed. The two paths are idempotent and memoized (the first clears the `sessions` map; a second caller awaits the same teardown promise). ## Known limitations (tracked TODOs) - **`TODO(rfc010-permission-gate)`** — the `tools/execute` permission gate (`session/request_permission`) is NOT implemented; tools run with the executor's full authority. The `agent→sessionId` reverse map is in place so the gate can route a permission request (which receives only `exec.agent`) back to its originating session. [ACP support](../../docs/rfc/proposed/2026-06-14-acp-agent-client-protocol.md) and [ACP multi-session](../../docs/rfc/proposed/2026-06-14-acp-multi-session.md) stay `proposed` until the gate (and per-session permission ownership) land. -- **`TODO(rfc010-cancel-prestep)`** — `session/cancel` is now the queue-aware `agent.cancel()` (a running step is aborted, queued + steering work is cleared, and a turn about to start is dropped), so a queued-but-not-yet-started prompt no longer runs and a later prompt cannot be batched into the cancelled turn. **Teardown/disconnect still use the older `agent.abort('disposed')` + `whenIdle()`**, so the best-effort window remains there: disposal/disconnect can return while one short queued turn per session still runs. PR D's per-agent disposer switches teardown to the queue-aware path and closes this; the single-in-flight-per-session rule bounds the worst case to one extra prompt per session until then. -- **`TODO(rfc010-agent-disposal)`** — the factory (`ctx.agents.create`/`resume`) returns no per-agent disposer, so teardown aborts+drains each agent but cannot individually unregister it; on a bare client disconnect (no host dispose) the idled agents linger in `ctx.agents` until the host context disposes. A reconnect spins up a fresh context, so this strands no work; a per-agent disposal seam is the follow-up. - **`additionalDirectories`** — rejected. A session operates in its single `cwd` (see Per-session cwd); widening the tool/filesystem scope to extra roots is a separate sandbox concern, not yet implemented. ## stdout is the protocol diff --git a/packages/acp/src/index.ts b/packages/acp/src/index.ts index 040f4b10d1..7edc2dae0d 100644 --- a/packages/acp/src/index.ts +++ b/packages/acp/src/index.ts @@ -138,6 +138,13 @@ export const Config: Schema = Schema.object({ interface SessionRecord { sessionId: string agent: Agent + /** + * The owned-agent disposer (from the {@link AgentHandle} the factory returned). + * Teardown calls it to unregister this ONE agent, stop its loop, await + * quiescence, and remove its session — instead of leaving it for the bridge + * fiber to reclaim. + */ + dispose: () => Promise /** * Resolves tool-owned presentation for THIS session's tool calls and remembers * each in-flight call's `(name, args)` so the matching `tool/result` can find @@ -434,14 +441,21 @@ export function apply(ctx: Context, config: AcpConfig): void { validateWorkspaceParams(params) validateMcpServers(params) const sessionId = randomUUID() - const agent = agents.create({ + const handle = agents.create({ agentId: sessionId, sessionId, meta: { cwd: params.cwd }, agentOptions: agentOptions(config), }) - bySession.set(agent, sessionId) - sessions.set(sessionId, { sessionId, agent, presenter: makePresenter(), terminalEnabled: terminalOutputCap, inflight: undefined }) + bySession.set(handle.agent, sessionId) + sessions.set(sessionId, { + sessionId, + agent: handle.agent, + dispose: () => handle.dispose(), + presenter: makePresenter(), + terminalEnabled: terminalOutputCap, + inflight: undefined, + }) return Promise.resolve({ sessionId }) }, @@ -483,30 +497,38 @@ export function apply(ctx: Context, config: AcpConfig): void { throw invalidParams(`session ${params.sessionId} cwd mismatch: persisted ${persistedCwd}, requested ${params.cwd}`) } } - const agent = await agents.resume({ + const handle = await agents.resume({ agentId: params.sessionId, resumeSessionId: params.sessionId, agentOptions: agentOptions(config), }) // The bridge may have torn down (disposal / client disconnect) while // resume() was pending. Its listeners are gone, so installing a record - // now would resurrect a live agent the bridge can no longer drive or - // tear down. Bail: the just-resumed agent is reclaimed with the host - // context (no per-agent disposer — TODO(rfc010-agent-disposal)). - /* v8 ignore next 3 -- the in-memory test transport rejects the in-flight + // now would resurrect a live agent the bridge can no longer drive. Bail — + // and tear down the just-resumed agent (unregister + stop + remove its + // session) before throwing, so it does not leak: it has no SessionRecord, + // so quiesce() would never see it. + /* v8 ignore next 4 -- the in-memory test transport rejects the in-flight session/load request the instant it closes (before this post-await code runs), so the guard can't be hit in tests; it protects the real stdio path, where a closed pipe need not reject a mid-flight handler. */ if (closed) { + await handle.dispose() throw invalidParams('connection closed during session/load') } + const agent = handle.agent bySession.set(agent, params.sessionId) // Snapshot the terminal capability ONCE for this session (used by both // the replay below and the post-load live stream) so a later // `initialize` can't desync the call/result of a tool card. const terminalEnabled = terminalOutputCap const record: SessionRecord = { - sessionId: params.sessionId, agent, presenter: makePresenter(), terminalEnabled, inflight: undefined, + sessionId: params.sessionId, + agent, + dispose: () => handle.dispose(), + presenter: makePresenter(), + terminalEnabled, + inflight: undefined, } sessions.set(params.sessionId, record) // Replay the persisted event log to the client as session/update. Use @@ -604,35 +626,27 @@ export function apply(ctx: Context, config: AcpConfig): void { /** * Tear ALL live sessions down to quiescence (AGENTS.md "dispose must reach - * quiescence"): for each session settle any pending prompt `cancelled`, abort - * the agent, and AWAIT it draining via the interface-level `whenIdle()` signal - * (NOT `agent/status('disposed')`, which fires before the driver exits). The - * agents drain in parallel. Idempotent — clears the `sessions` map first and - * memoizes, so a second call (close racing dispose) is a no-op. + * quiescence"): for each session settle any pending prompt `cancelled`, then + * run that session's {@link AgentHandle} `dispose()` — which stops the loop + * with the queue-aware cancel, AWAITS the loop's exit (the final + * `turn/end` + `session/flush` are captured while `onAppend` is still + * attached), unregisters the agent, and removes its session from the store. + * The per-session disposes run in parallel. Idempotent — clears the `sessions` + * map first and memoizes, so a second call (close racing dispose) is a no-op. * Shared by Cordis disposal AND client disconnect (`conn.closed`). * - * Caveat (same window as TODO(rfc010-cancel-prestep)): if teardown lands in - * the pre-step window — `agent.send()` queued a turn but the loop has not yet - * flipped to `running` — `abort()` has no live `AbortController` to signal and - * `whenIdle()` returns immediately (status is still `idle`), so that queued - * turn may still start and run after teardown returns. Reaching true - * quiescence in that window needs a queue-aware loop cancel primitive (a - * loop-level change); the single-in-flight-per-session rule bounds the worst - * case to one short queued turn per session. - * - * The agents are NOT individually disposed/unregistered here. The factory - * (`ctx.agents.create`/`resume`) registers each via `AgentLoop.start`'s - * `this.ctx.effect(...)`; because the factory is reached through this bridge's - * traceable service proxy, that effect's `this.ctx` is the CALLER context (the - * bridge fiber), so every registry entry is bound to the bridge fiber and is - * reclaimed when the bridge fiber disposes (whole-context dispose, or an - * ACP-only HMR `acpFiber.dispose()` — both unregister all the bridge's - * agents). What this teardown path handles is a bare client disconnect, which - * resolves `conn.closed` WITHOUT disposing the fiber: each live agent is - * idled+aborted here but stays in `ctx.agents` until the fiber is disposed. - * Since a reconnect spins up a fresh context, the lingering idle agents strand - * no work. A per-agent disposal seam (unregister on disconnect) is a follow-up - * (TODO(rfc010-agent-disposal)). + * Per-agent disposal closes the former pre-step best-effort window: the + * queue-aware `cancel()` (RFC 011) drops a turn about to start, so a queued- + * but-not-yet-running prompt never runs after teardown. A bare client + * disconnect (resolves `conn.closed` WITHOUT disposing the fiber) thus leaves + * NO registered agent and NO session-store entry — not an idled-but-still- + * registered one. When the fiber IS disposed (whole-context or an ACP-only HMR + * `acpFiber.dispose()`), this same memoized teardown runs first; the factory's + * register+start+session effects are ALSO bound to the bridge fiber (the + * factory is reached through this bridge's traceable service proxy, so + * `AgentLoop.start`'s `this.ctx.effect(...)` binds to the CALLER context — the + * bridge fiber), so any agent this path did not reach is still reclaimed by + * fiber disposal. */ let quiescing: Promise | undefined const quiesce = (): Promise => { @@ -650,8 +664,12 @@ export function apply(ctx: Context, config: AcpConfig): void { quiescing = (async () => { await Promise.all(recs.map(async (rec) => { settlePrompt(rec, 'cancelled') - rec.agent.abort('disposed') - await rec.agent.whenIdle() + // Per-agent dispose (the AgentHandle disposer): unregister this agent, + // stop its loop with the queue-aware cancel, await quiescence (the loop + // exit + final flush), and remove its session — so a bare client + // disconnect leaves NO registered agent and NO session-store entry, not + // just an idled-but-still-registered one. + await rec.dispose() })) })() return quiescing diff --git a/packages/acp/tests/dispose.spec.ts b/packages/acp/tests/dispose.spec.ts index 45e351ced5..6ff89b400e 100644 --- a/packages/acp/tests/dispose.spec.ts +++ b/packages/acp/tests/dispose.spec.ts @@ -3,7 +3,8 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' -import { makeBridgeHarness } from './harness.ts' +import { SessionId } from '@deepseek-ai/dsh-session' +import { makeBridgeHarness, textResponse } from './harness.ts' describe('acp bridge — disposal & HMR safety', () => { let storageDir: string @@ -82,10 +83,11 @@ describe('acp bridge — disposal & HMR safety', () => { await harness.dispose() }) - it('a client disconnect mid-prompt tears the session down to quiescence', async () => { + it('a client disconnect mid-prompt disposes the session (no registered agent left)', async () => { // The ACP transport closes (editor quits) while a turn runs. The bridge must - // settle the in-flight prompt cancelled and abort+drain the agent rather - // than leaving an orphaned running agent whose updates are swallowed. + // settle the in-flight prompt cancelled and DISPOSE the agent (PR D's + // per-agent AgentHandle teardown) rather than leaving an orphaned running — + // or even idled-but-still-registered — agent whose updates are swallowed. const harness = await makeBridgeHarness({ storageDir, script: ['hang'] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) @@ -96,13 +98,25 @@ describe('acp bridge — disposal & HMR safety', () => { await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('running') - // Sever the transport — the bridge's conn.closed teardown runs and drives - // the agent to quiescence on its OWN (assert before any dispose() runs). + // Sever the transport — the bridge's conn.closed teardown runs and drives the + // agent's AgentHandle dispose to quiescence on its OWN (before any dispose()). await harness.closeClientTransport() await agent.whenIdle() - expect(agent.status).toBe('idle') + // The agent's loop has stopped: status `disposed`. + expect(agent.status).toBe('disposed') - await harness.dispose() // idempotent with the close teardown + // Await the bridge teardown to completion WITHOUT tearing down the root + // agents/sessions services (so we can still query them). acpFiber.dispose() + // invokes the SAME memoized quiesce() the disconnect started and awaits its + // promise — which resolves only after every rec.dispose() (loop exit + + // session removal) has finished, closing the whenIdle()/owned.dispose() + // microtask race. The AgentHandle dispose has run: the agent is unregistered + // and its session removed from the store, not merely idled (the old + // behavior). The services live on the root ctx, so they survive this. + await harness.acpFiber.dispose() + expect(harness.ctx.agents.get(sessionId)).toBeUndefined() + expect(harness.ctx.sessions.get(sessionId)).toBeUndefined() + await harness.dispose() }) it('a client disconnect racing fiber dispose both reach quiescence (shared teardown)', async () => { @@ -140,4 +154,61 @@ describe('acp bridge — disposal & HMR safety', () => { await new Promise(r => setTimeout(r, 10)) expect(harness.updates.length).toBe(before) }) + + it('the final turn closing events are persisted across an AgentHandle dispose (durability)', async () => { + // The teardown-ORDER guarantee: a per-agent dispose must stop the loop, + // AWAIT its exit (so the loop's final `turn/end` + `session/flush` fire + // through the still-attached `session.onAppend` → `session/event`), and only + // THEN detach onAppend + remove the session. If the order were inverted + // (detach first), the closing events would never reach persistence. Drive a + // CLEAN turn to completion, dispose JUST the bridge, then re-load the + // persisted log from disk and assert the closing turn/end is on disk — the + // world, not the agent's self-report. + const harness = await makeBridgeHarness({ storageDir, script: [textResponse('done')] }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) + const liveEvents = harness.ctx.agents.get(sessionId)!.session.events.length + expect(liveEvents).toBeGreaterThan(0) + + // Tear down JUST the bridge (the AgentHandle dispose runs to quiescence). + await harness.acpFiber.dispose() + expect(harness.ctx.agents.get(sessionId)).toBeUndefined() + + // Re-load the session from disk: every live event (incl. the closing + // turn/end) was flushed before the session was detached. + const reloaded = await harness.ctx.sessionPersistence.load(SessionId(sessionId)) + expect(reloaded.events.length).toBe(liveEvents) + const last = reloaded.events.at(-1)! + expect(last.type).toBe('turn/end') + await harness.dispose() + }) + + it('per-session AgentHandle dispose leaves sibling agents untouched', async () => { + // The factory returns a per-agent AgentHandle whose dispose() tears down + // EXACTLY that agent + its session — RFC 011 isolation. Create two agents + // directly through the registry factory (the same path the ACP bridge uses), + // dispose one handle, and assert the other survives, registered and + // queryable, with its session still in the store. + const harness = await makeBridgeHarness({ storageDir, script: [] }) + const handleA = harness.ctx.agents.create({ + agentId: 'sib-a', sessionId: 'sib-a', agentOptions: { model: 'mock' }, + }) + const handleB = harness.ctx.agents.create({ + agentId: 'sib-b', sessionId: 'sib-b', agentOptions: { model: 'mock' }, + }) + expect(harness.ctx.agents.get('sib-a')).toBe(handleA.agent) + expect(harness.ctx.agents.get('sib-b')).toBe(handleB.agent) + + await handleA.dispose() + // A is gone — unregistered AND its session removed from the store. + expect(harness.ctx.agents.get('sib-a')).toBeUndefined() + expect(harness.ctx.sessions.get('sib-a')).toBeUndefined() + expect(handleA.agent.status).toBe('disposed') + // B is wholly unaffected. + expect(harness.ctx.agents.get('sib-b')).toBe(handleB.agent) + expect(harness.ctx.sessions.get('sib-b')).toBeDefined() + expect(handleB.agent.status).not.toBe('disposed') + await harness.dispose() + }) }) diff --git a/packages/acp/tests/edges.spec.ts b/packages/acp/tests/edges.spec.ts index 9484368322..b9e2377908 100644 --- a/packages/acp/tests/edges.spec.ts +++ b/packages/acp/tests/edges.spec.ts @@ -25,7 +25,7 @@ describe('acp bridge — demux & config edges', () => { await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) const before = harness.updates.length - const foreign = harness.ctx.agents.create({ agentId: 'foreign', sessionId: 'foreign-session', agentOptions: { model: 'mock' } }) + const { agent: foreign } = harness.ctx.agents.create({ agentId: 'foreign', sessionId: 'foreign-session', agentOptions: { model: 'mock' } }) foreign.send([{ type: 'text', text: 'hi' }]) await foreign.whenIdle() await new Promise(r => setTimeout(r, 10)) diff --git a/packages/agent-loop/README.md b/packages/agent-loop/README.md index e5e3a03d2d..5d79d90147 100644 --- a/packages/agent-loop/README.md +++ b/packages/agent-loop/README.md @@ -12,8 +12,10 @@ This is the only package in the harness that contains concrete loop logic. Every `AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface): -- `ctx.agents.create({ agentId, sessionId, meta?, agentOptions? })` — programmatic create on a caller-supplied `sessionId` (e.g. an ACP-generated id), NOT `${id}-session`. -- `ctx.agents.resume({ agentId, resumeSessionId, agentOptions? })` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../docs/rfc/implemented/2026-06-14-session-persistence.md)) and resume an agent on it. The live session id is the resumed id; turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). +- `ctx.agents.create({ agentId, sessionId, meta?, agentOptions? }): AgentHandle` — programmatic create on a caller-supplied `sessionId` (e.g. an ACP-generated id), NOT `${id}-session`. Returns an [`AgentHandle`](../agent/README.md) — the owner disposes it to tear down exactly this agent (stop loop + await quiescence + unregister + remove session). +- `ctx.agents.resume({ agentId, resumeSessionId, agentOptions? }): Promise` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../docs/rfc/implemented/2026-06-14-session-persistence.md)) and resume an agent on it. The live session id is the resumed id; turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). Returns an `AgentHandle`. + +The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle) — only the programmatic factory callers (the ACP bridge) hold a handle and own per-agent teardown. ### Injected services diff --git a/packages/agent/README.md b/packages/agent/README.md index d815761ae4..846ab17444 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -17,8 +17,10 @@ Tracks live agents so UI, hook, and orchestrator plugins can find them without i Agent *creation* is provided by whichever plugin implements `AgentFactory` (phase 1: `dsh-agent-loop`), registered via `setFactory`. This keeps creation on the `dsh-agent` interface so consumers (UI, the ACP bridge) program against `ctx.agents` without depending on the concrete loop package. - `ctx.agents.setFactory(factory: AgentFactory): () => void` — register the creation factory (the loop calls this on construction). Throws on a second factory; the slot clears on dispose. -- `ctx.agents.create(options: CreateAgentOptions): Agent` — construct, start, AND register a new agent on a caller-supplied `sessionId` (with optional `meta.cwd`). Distinct from `register` (which only records). Throws if no factory is registered. -- `ctx.agents.resume(options: ResumeAgentOptions): Promise` — load a persisted session ([session persistence](../../docs/rfc/implemented/2026-06-14-session-persistence.md)) and resume an agent on it. Async; rejects if no factory is registered, or if the factory finds session persistence unconfigured. +- `ctx.agents.create(options: CreateAgentOptions): AgentHandle` — construct, start, AND register a new agent on a caller-supplied `sessionId` (with optional `meta.cwd`). Distinct from `register` (which only records). Throws if no factory is registered. +- `ctx.agents.resume(options: ResumeAgentOptions): Promise` — load a persisted session ([session persistence](../../docs/rfc/implemented/2026-06-14-session-persistence.md)) and resume an agent on it. Async; rejects if no factory is registered, or if the factory finds session persistence unconfigured. + +`AgentHandle = { agent: Agent; dispose(): Promise }`. The disposer is a **capability** — only the holder can tear this agent down. `dispose()` stops the loop, `await`s its exit (quiescence — NOT just the `disposed` status flip), unregisters the agent, and removes its session from the store, in an order that captures the loop's final `session/flush` before the session is detached. `ctx.agents.get(id)` still returns a bare `Agent` — the handle is only for the OWNER that created it. The ACP bridge is the production consumer (one handle per session, disposed on disconnect/teardown); config-created agents are owned by the loop fiber and never need a handle. ### Events