Преглед изворни кода

Merge pull request #42 from deepseek-ai/feat/acp-3-multi-session

feat(acp): multiplex N concurrent ACP sessions + bash task ownership (RFC 011)
Tianyi Cui пре 3 месеци
родитељ
комит
dfc11d22e7

+ 1 - 0
docs/rfc/proposed/2026-06-14-acp-multi-session.md

@@ -3,6 +3,7 @@
 Status: proposed
 
 <!-- XXX: legacy ADR/RFC body format, not yet normalized to a unified RFC template. -->
+> **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 RFC 010 permission gate (`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.
 
 ## Problem
 

+ 1 - 1
examples/acp-agent/README.md

@@ -32,4 +32,4 @@ Run from the repo root (the MVP requires the server's launch directory to be the
 
 ## MVP limitations
 
-The bridge is the RFC 010 MVP: single session per connection (RFC 011 lifts this), text-only prompts, `cwd` must equal the launch directory, and the tool-permission gate is deferred (`TODO(rfc010-permission-gate)` — tools run with the executor's full authority). See `packages/acp/README.md` for the full contract.
+The bridge supports N concurrent sessions per connection (RFC 011). Remaining limits: text-only prompts, `cwd` must equal the launch directory, and the tool-permission gate is deferred (`TODO(rfc010-permission-gate)` — tools run with the executor's full authority). See `packages/acp/README.md` for the full contract.

+ 15 - 9
packages/acp/README.md

@@ -1,6 +1,6 @@
 # @deepseek-ai/dsh-acp
 
-The **Agent Client Protocol (ACP)** bridge: exposes the DeepSeek Harness coding agent as an ACP server over JSON-RPC stdio, so editors (Zed and other ACP clients) can drive it — streaming render, tool-call display, and resumable sessions.
+The **Agent Client Protocol (ACP)** bridge: exposes the DeepSeek Harness coding agent as an ACP server over JSON-RPC stdio, so editors (Zed and other ACP clients) can drive it — streaming render, tool-call display, and resumable sessions. **N concurrent sessions per connection** (RFC 011): each maps to its own `LoopAgent`, and every event is demuxed strictly by session id so two sessions streaming at once never interleave.
 
 It is a **client-driver / UI plugin**, the structured analogue of the readline `stdio-chat` plugin — NOT a loop change and NOT a [capability seam](../../docs/rfc/implemented/2026-06-13-capability-seams.md). It consumes the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory, and `dsh-session-persistence`.
 
@@ -24,25 +24,31 @@ It is a **client-driver / UI plugin**, the structured analogue of the readline `
 | ACP method | Harness seam | Notes |
 |---|---|---|
 | `initialize` | static | negotiate `protocolVersion`; advertise text-only `promptCapabilities` and `loadSession: true` |
-| `session/new` | `ctx.agents.create({ sessionId, meta:{cwd} })` | single-session MVP (a 2nd is rejected — RFC 011 lifts this); `cwd` must be absolute AND equal the server launch dir; `additionalDirectories` rejected; `mcpServers` ignored |
-| `session/load` | `ctx.agents.resume(...)` | replays the persisted event log to the client as `session/update` — the USER side (`user/message` → `user_message_chunk`), assistant text/reasoning (`assistant/chunk`), and tool calls/results (`tool/call` + `tool/result`). The single-session slot is reserved (`loading`) BEFORE the async resume so a pipelined `load`/`new` can't leak a second agent; the PERSISTED header `cwd` is validated via a metadata-only `list()` BEFORE resume (not just the requested `cwd`), so a mismatch rejects without ever constructing an agent. After the async resume a `closed` re-check refuses to install a record if the bridge tore down mid-load |
-| `session/prompt` | `agent.send()` | text-only; rejects image/audio and empty prompts; one in-flight prompt; settles on the OWNING turn's end (a turn that ends in `error` rejects the RPC) |
-| `session/cancel` | `agent.abort()` | aborts a running step + settles the prompt `cancelled` (see limitation below) |
+| `session/new` | `ctx.agents.create({ sessionId, meta:{cwd} })` | creates a new session/agent; N concurrent sessions are allowed (RFC 011), keyed by id; `cwd` must be absolute AND equal the server launch dir; `additionalDirectories` rejected; `mcpServers` ignored |
+| `session/load` | `ctx.agents.resume(...)` | replays the persisted event log to the client as `session/update` — the USER side (`user/message` → `user_message_chunk`), assistant text/reasoning (`assistant/chunk`), and tool calls/results (`tool/call` + `tool/result`). Re-loading an already-live id is rejected; the id's load slot is reserved (`loadingIds`) BEFORE the async resume so a pipelined load of the SAME id can't leak a second agent (distinct ids load concurrently); the PERSISTED header `cwd` is validated via a metadata-only `list()` BEFORE resume (not just the requested `cwd`), so a mismatch rejects without ever constructing an agent. After the async resume a `closed` re-check refuses to install a record if the bridge tore down mid-load |
+| `session/prompt` | `agent.send()` | text-only; rejects image/audio and empty prompts; one in-flight prompt PER session (independent); settles on the OWNING turn's end (a turn that ends in `error` rejects the RPC) |
+| `session/cancel` | `agent.abort()` | aborts a running step + settles the prompt `cancelled` for ONLY that session — a cancel never touches another session's stream or prompt (see limitation below) |
 | `session/update` | `session/event` | `agent_message_chunk` (text-delta), `agent_thought_chunk` (reasoning-delta), `user_message_chunk` (load replay), `tool_call`/`tool_call_update` |
 
+## Multi-session (RFC 011)
+
+The bridge multiplexes N sessions over one connection. Live sessions are held in a `Map<sessionId, SessionRecord>` (forward) with a `WeakMap<Agent, sessionId>` reverse map so `agent/*` events — which carry only the `Agent` — demux in O(1). Every `session/event` and `agent/status` is routed strictly to its owning record, so concurrent sessions never cross-settle or interleave their `session/update` notifications. State is per session: one in-flight prompt each, `session/cancel` aborts and settles only its own agent/prompt, and disposal drains every live session in parallel to quiescence. (Per-session *permission* ownership is reserved for the deferred permission gate — `TODO(rfc010-permission-gate)`.)
+
+Background-task isolation rides on `dsh-tool-bash`: bash task ids are global and predictable, so the tool layer records each background task's owning agent and `bash_output`/`bash_kill` reject a task owned by a different agent — one session's agent can't read or kill another's task.
+
 ## Settle-exactly-once
 
 A `session/prompt` resolves (or rejects) exactly once, keyed off the canonical session log (the `session/event` stream), NOT the `agent/turn-start`/`agent/turn-end` events. One listener captures the prompt's owning turn from the log's `turn/start` and settles on the matching `turn/end` — the one signal that always fires (`closeTurn` appends it unconditionally, even when a boundary emit throws and the `agent/turn-end` EVENT is skipped). A prompt settles only on ITS OWN turn (`inflight.turn === turn/end.turn`), so a stale `turn/end` for a previously-cancelled turn whose end arrives late can never settle the wrong prompt. A turn that ends `error` REJECTS the RPC with an internal error carrying the failure message (ACP has no error stop reason); every other reason resolves via the codec. As a fallback, when the agent settles to `idle`/`disposed` with a prompt still pending — e.g. a `session/event` listener registered before the bridge threw and starved the bridge's listener — an `agent/status` handler reconciles the prompt from the log (the owning turn's `turn/end`, or `cancelled` if the turn was torn down without one). An empty/whitespace prompt is rejected up front — it would queue no work, so no turn would start and the RPC would hang.
 
 ## Disposal & disconnect
 
-Teardown reaches quiescence: 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 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 (each clears the record first).
+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).
 
 ## Known limitations (tracked TODOs)
 
-- **`TODO(rfc010-permission-gate)`** — the `tools/execute` permission gate (`session/request_permission`) is NOT implemented in this PR; tools run with the executor's full authority. The ownership `WeakMap<Agent, sessionId>` seam is laid down so the gate (and RFC 011 per-session permission ownership) can build on it. RFC 010 stays `proposed` until the gate lands.
-- **`TODO(rfc010-cancel-prestep)`** — `session/cancel` (and teardown/disconnect) is honest RPC/UI cancellation plus best-effort abort: a *running* step is aborted, but a turn that is queued-but-not-yet-started (the gap before `agent.abort()` has an `AbortController` to signal) may still run to completion. This same window means disposal/disconnect can return while one short queued turn still runs, and a prompt accepted right after a pre-step cancel can be batched into the cancelled turn (the loop merges queued messages into one turn). A loop-level queue-aware cancel will close this; the single-in-flight-prompt rule bounds the worst case to one extra prompt.
-- **`TODO(rfc010-agent-disposal)`** — the factory (`ctx.agents.create`/`resume`) returns no per-agent disposer, so teardown aborts+drains the agent but cannot individually unregister it; on a bare client disconnect (no host dispose) the idled agent lingers in `ctx.agents` until the host context disposes. Single-session-per-connection makes this benign today (a reconnect spins up a fresh context); RFC 011 adds the per-session disposal seam.
+- **`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. RFC 010/011 stay `proposed` until the gate (and per-session permission ownership) land.
+- **`TODO(rfc010-cancel-prestep)`** — `session/cancel` (and teardown/disconnect) is honest RPC/UI cancellation plus best-effort abort: a *running* step is aborted, but a turn that is queued-but-not-yet-started (the gap before `agent.abort()` has an `AbortController` to signal) may still run to completion. This same window means disposal/disconnect can return while one short queued turn per session still runs, and a prompt accepted right after a pre-step cancel can be batched into the cancelled turn (the loop merges queued messages into one turn). A loop-level queue-aware cancel will close this; the single-in-flight-per-session rule bounds the worst case to one extra prompt per session.
+- **`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.
 - **`cwd`** — only the server's launch directory is honored; a `session/new.cwd` (or a persisted `session/load` header cwd) that differs is rejected (RFC 010 § Deferred — no path from session cwd to the bash workdir yet).
 
 ## stdout is the protocol

+ 85 - 81
packages/acp/src/index.ts

@@ -15,9 +15,13 @@
  *                      that ends in `error` rejects the RPC)
  * - `session/cancel` → `agent.abort()` + settle the in-flight prompt
  *
- * Single-session for the MVP (a 2nd `session/new` is rejected); RFC 011 lifts
- * that. The `tools/execute` permission gate is deferred — see the
- * TODO(rfc010-permission-gate) note below.
+ * Multi-session (RFC 011): N concurrent sessions per connection, each mapped to
+ * its own `LoopAgent`. Sessions are keyed by id in `sessions` (forward) with an
+ * `agent→sessionId` reverse map for O(1) demux of `agent/*` events; every
+ * `session/event` and `agent/*` event is routed strictly to its owning session
+ * record, so two sessions streaming at once never interleave their
+ * `session/update` notifications. The `tools/execute` permission gate is
+ * deferred — see the TODO(rfc010-permission-gate) note below.
  *
  * stdout is the protocol: this plugin must run in an example that loads NO
  * stdout logger (the console logger writes to stdout and would corrupt the
@@ -121,9 +125,8 @@ export const Config: Schema<AcpConfig> = Schema.object({
 })
 
 /**
- * Per-session bridge state. Single-entry in this MVP (RFC 011 makes the maps
- * multi-entry); kept as a record from the start so RFC 011 generalizes the
- * container, not the shape.
+ * Per-session bridge state. One per live ACP session; held in the `sessions`
+ * map keyed by id (RFC 011 multi-session).
  */
 interface SessionRecord {
   sessionId: string
@@ -180,23 +183,21 @@ export function apply(ctx: Context, config: AcpConfig): void {
   const sessionPersistence = ctx.sessionPersistence
   const logger = ctx.logger
 
-  // Single live session for the MVP. RFC 011 turns this into maps keyed by
-  // sessionId plus an agent→sessionId reverse map for the permission gate.
-  let record: SessionRecord | undefined
-  // True while a `session/load` is between reserving the single-session slot and
-  // installing its `record` (resume() is async). The session guards check BOTH
-  // `record` and `loading` so a pipelined load/new cannot slip past while the
-  // first load's resume() is pending and leak a second live agent.
-  let loading = false
+  // Live sessions keyed by id (RFC 011 multi-session), plus an agent→sessionId
+  // reverse map so `agent/*` events (which carry only the Agent) demux in O(1).
+  // The two stay in lockstep: a record is added to `sessions` and the agent to
+  // `bySession` together, and removed together.
+  const sessions = new Map<string, SessionRecord>()
+  const bySession = new WeakMap<Agent, string>()
+  // Session ids whose `session/load` is mid-`resume()` (the slot is reserved
+  // before the async resume so a pipelined load/new for the SAME id can't create
+  // two agents). Distinct ids load concurrently; a given id loads once at a time.
+  const loadingIds = new Set<string>()
   // Set once the bridge has torn down (disposal or client disconnect). An async
-  // `session/load` that was mid-`resume()` when teardown ran must observe this
-  // after its await and NOT install a `record` (which would resurrect a live
-  // agent/listeners after the bridge closed). Checked after every load await.
+  // `session/load` mid-`resume()` when teardown ran must observe this after its
+  // await and NOT install a record (which would resurrect a live agent/listeners
+  // after the bridge closed). Checked after every load await.
   let closed = false
-  // Ownership marker: agents this bridge created. The deferred permission gate
-  // (TODO(rfc010-permission-gate)) and RFC 011 build on this; laid down now so
-  // the seam exists. A WeakMap so a disposed agent's entry is collectable.
-  const owned = new WeakMap<Agent, string>()
 
   // Assigned at the bottom, before any agent event can fire (a session only
   // exists after `newSession`, which the client calls after construction), so
@@ -218,10 +219,11 @@ export function apply(ctx: Context, config: AcpConfig): void {
 
   /** Resolve the live record for a sessionId, or throw an ACP error. */
   const requireSession = (sessionId: string): SessionRecord => {
-    if (record === undefined || record.sessionId !== sessionId) {
+    const rec = sessions.get(sessionId)
+    if (rec === undefined) {
       throw invalidParams(`unknown session: ${sessionId}`)
     }
-    return record
+    return rec
   }
 
   /** Push a `session/update` notification, swallowing post-close rejections. */
@@ -263,10 +265,11 @@ export function apply(ctx: Context, config: AcpConfig): void {
   // already-cancelled turn whose end arrives late is ignored (see
   // SessionRecord.inflight). A turn that ends `error` REJECTS the prompt (ACP
   // has no error stop reason); other reasons resolve via the codec. Demux
-  // strictly by session id.
+  // strictly by session id: a `session/event` is routed to its own record, so
+  // two sessions streaming at once never cross-settle or interleave updates.
   ctx.on('session/event', (session, event: SessionEvent) => {
-    const rec = record
-    if (rec === undefined || session.header.id !== rec.sessionId) return
+    const rec = sessions.get(session.header.id)
+    if (rec === undefined) return
     streamSessionEventUpdate(rec.sessionId, event, notify)
     const inflight = rec.inflight
     if (inflight === undefined) return
@@ -344,9 +347,12 @@ export function apply(ctx: Context, config: AcpConfig): void {
   // On a settle to idle/disposed, reconcile any still-pending prompt from the
   // log (covers a starved `session/event` listener — see settleFromLog). A mid-
   // step disposal that never appended a clean turn/end resolves `cancelled`.
+  // Demux via the agent→sessionId reverse map.
   ctx.on('agent/status', (agent, status: AgentStatus) => {
-    const rec = record
-    if (rec === undefined || owned.get(agent) !== rec.sessionId) return
+    const sessionId = bySession.get(agent)
+    if (sessionId === undefined) return
+    const rec = sessions.get(sessionId)
+    if (rec === undefined) return
     if (status === 'idle' || status === 'disposed') settleFromLog(rec)
   })
 
@@ -380,9 +386,6 @@ export function apply(ctx: Context, config: AcpConfig): void {
 
       newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
         assertOpen()
-        if (record !== undefined || loading) {
-          throw invalidParams('this agent supports a single session; a session already exists (RFC 011 will lift this)')
-        }
         validateWorkspaceParams(params)
         const sessionId = randomUUID()
         const agent = agents.create({
@@ -391,24 +394,23 @@ export function apply(ctx: Context, config: AcpConfig): void {
           meta: { cwd: params.cwd },
           agentOptions: agentOptions(config),
         })
-        owned.set(agent, sessionId)
-        record = { sessionId, agent, inflight: undefined }
+        bySession.set(agent, sessionId)
+        sessions.set(sessionId, { sessionId, agent, inflight: undefined })
         return Promise.resolve({ sessionId })
       },
 
       async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
         assertOpen()
-        if (record !== undefined || loading) {
-          throw invalidParams('this agent supports a single session; a session already exists (RFC 011 will lift this)')
+        if (sessions.has(params.sessionId) || loadingIds.has(params.sessionId)) {
+          throw invalidParams(`session ${params.sessionId} is already loaded`)
         }
         validateWorkspaceParams(params)
-        // Reserve the single-session slot BEFORE the await. Without this, two
-        // pipelined load/new requests could both pass the guard above while the
-        // first load's resume() is pending, then both install a record and leak
-        // a second live agent. `loading` claims the slot; it is cleared in
-        // `finally` so a rejected load (bad id, cwd mismatch) never wedges all
-        // future sessions on this connection.
-        loading = true
+        // Reserve THIS id's load slot BEFORE the await. Without it, two pipelined
+        // loads for the same id could both pass the guard above while the first
+        // resume() is pending, then both install a record and leak a second
+        // agent. (Distinct ids load concurrently — the set is keyed by id.) The
+        // slot is released in `finally` so a rejected load never wedges the id.
+        loadingIds.add(params.sessionId)
         try {
           // Validate the PERSISTED cwd BEFORE resuming — `list()` is a
           // metadata-only read (no full-log parse) — so a mismatch rejects
@@ -430,7 +432,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
             agentOptions: agentOptions(config),
           })
           // The bridge may have torn down (disposal / client disconnect) while
-          // resume() was pending. Its listeners are gone, so installing `record`
+          // 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)).
@@ -441,8 +443,8 @@ export function apply(ctx: Context, config: AcpConfig): void {
           if (closed) {
             throw invalidParams('connection closed during session/load')
           }
-          owned.set(agent, params.sessionId)
-          record = { sessionId: params.sessionId, agent, inflight: undefined }
+          bySession.set(agent, params.sessionId)
+          sessions.set(params.sessionId, { sessionId: params.sessionId, agent, inflight: undefined })
           // Replay the persisted event log to the client as session/update. Use
           // the raw event log (NOT deriveMessages, which drops assistant/chunk
           // and trace events): RFC 010's load contract reconstructs the streamed
@@ -453,7 +455,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
           }
           return {}
         } finally {
-          loading = false
+          loadingIds.delete(params.sessionId)
         }
       },
 
@@ -488,13 +490,15 @@ export function apply(ctx: Context, config: AcpConfig): void {
       },
 
       cancel(params: CancelNotification): Promise<void> {
-        const rec = record
-        if (rec === undefined || rec.sessionId !== params.sessionId) return Promise.resolve()
+        const rec = sessions.get(params.sessionId)
+        if (rec === undefined) return Promise.resolve()
         // RFC 010: session/cancel maps to agent.abort(reason). This aborts a
         // RUNNING step (the turn ends 'aborted' → 'cancelled' via turn-end).
-        // It also settles the in-flight prompt as cancelled directly, in case
-        // the abort lands in the pre-step window (queued-but-not-started) where
-        // abort() has no AbortController to signal — see the README
+        // It aborts and settles ONLY this session's agent/prompt — a cancel in
+        // one session never touches another's stream or pending prompt (RFC 011
+        // isolation). It also settles the in-flight prompt as cancelled directly,
+        // in case the abort lands in the pre-step window (queued-but-not-started)
+        // where abort() has no AbortController to signal — see the README
         // TODO(rfc010-cancel-prestep): a not-yet-started queued turn may still
         // run to completion until a loop-level cancel lands. Best-effort abort
         // plus honest RPC/UI cancellation. A secondary consequence of that same
@@ -526,11 +530,12 @@ export function apply(ctx: Context, config: AcpConfig): void {
   conn = new AgentSideConnection(makeAgent, stream)
 
   /**
-   * Tear the live session down to quiescence (AGENTS.md "dispose must reach
-   * quiescence"): 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). Idempotent
-   * — clears `record` first, so a second call (close racing dispose) is a no-op.
+   * 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.
    * Shared by Cordis disposal AND client disconnect (`conn.closed`).
    *
    * Caveat (same window as TODO(rfc010-cancel-prestep)): if teardown lands in
@@ -539,43 +544,42 @@ export function apply(ctx: Context, config: AcpConfig): void {
    * `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, out of the RFC 010 MVP scope); for `newSession` agents
-   * the worst case is one short queued turn, since the bridge enforces a single
-   * in-flight prompt.
+   * loop-level change); the single-in-flight-per-session rule bounds the worst
+   * case to one short queued turn per session.
    *
-   * The agent itself is NOT individually disposed/unregistered here. The
-   * factory (`ctx.agents.create`/`resume`) registers it via `AgentLoop.start`'s
+   * 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 the registry entry is bound to the bridge fiber and is
+   * 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 the agent). What this
-   * teardown path handles is a bare client disconnect, which resolves
-   * `conn.closed` WITHOUT disposing the fiber: the agent is idled+aborted here
-   * but stays in `ctx.agents` until the fiber is disposed. Since the MVP is
-   * single-session-per-connection and a reconnect spins up a fresh context, the
-   * lingering idle agent strands no work. A per-agent disposal seam (unregister
-   * on disconnect) is an RFC 011 follow-up (TODO(rfc010-agent-disposal)).
+   * 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)).
    */
   let quiescing: Promise<void> | undefined
   const quiesce = (): Promise<void> => {
     // Memoize: disposal and client-disconnect can both fire. The first call owns
     // the teardown; later callers await the SAME promise so `fiber.dispose()`
-    // never returns before an in-flight close teardown has finished (using
-    // `record === undefined` as the only guard would let the second caller
-    // return early while the first is still awaiting whenIdle()).
+    // never returns before an in-flight close teardown has finished.
     if (quiescing !== undefined) return quiescing
-    // Mark closed BEFORE the record check: a `session/load` mid-`resume()` (no
-    // record installed yet) must observe this after its await and refuse to
-    // install a post-teardown record. Set even when there is nothing else to do.
+    // Mark closed BEFORE draining: a `session/load` mid-`resume()` (no record
+    // installed yet) must observe this after its await and refuse to install a
+    // post-teardown record. Set even when there are no live sessions.
     closed = true
-    const rec = record
-    record = undefined
-    if (rec === undefined) return Promise.resolve()
+    const recs = [...sessions.values()]
+    sessions.clear()
+    if (recs.length === 0) return Promise.resolve()
     quiescing = (async () => {
-      settlePrompt(rec, 'cancelled')
-      rec.agent.abort('disposed')
-      await rec.agent.whenIdle()
+      await Promise.all(recs.map(async (rec) => {
+        settlePrompt(rec, 'cancelled')
+        rec.agent.abort('disposed')
+        await rec.agent.whenIdle()
+      }))
     })()
     return quiescing
   }

+ 9 - 4
packages/acp/tests/bridge.spec.ts

@@ -52,12 +52,17 @@ describe('acp bridge', () => {
     expect(text).toBe('hello there')
   })
 
-  it('rejects a second session/new (single-session MVP)', async () => {
+  it('allows multiple concurrent sessions, each with a distinct id', async () => {
     harness = await makeBridgeHarness({ storageDir, script: [] })
     await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
-    await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
-    await expect(harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }))
-      .rejects.toThrow(/single session/)
+    const a = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
+    const b = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
+    expect(a.sessionId).toBeTruthy()
+    expect(b.sessionId).toBeTruthy()
+    expect(a.sessionId).not.toBe(b.sessionId)
+    // Both agents are live and independently registered.
+    expect(harness.ctx.agents.get(a.sessionId)).toBeDefined()
+    expect(harness.ctx.agents.get(b.sessionId)).toBeDefined()
   })
 
   it('rejects a non-absolute cwd and a cwd that differs from the launch dir', async () => {

+ 6 - 1
packages/acp/tests/harness.ts

@@ -107,8 +107,10 @@ export interface BridgeHarness {
   ctx: Context
   client: ClientSideConnection
   adapter: MockAdapter
-  /** Every `session/update` the bridge pushed, in order. */
+  /** Every `session/update` the bridge pushed, in order (payload only). */
   updates: CapturedUpdate[]
+  /** Same, but tagged with each update's `sessionId` (for multi-session demux assertions). */
+  sessionUpdates: { sessionId: string; update: CapturedUpdate }[]
   /** Permission requests the bridge issued (none until the gate lands). */
   permissionRequests: RequestPermissionRequest[]
   /** Decide each permission request's outcome (default: cancelled). */
@@ -179,11 +181,13 @@ export async function makeBridgeHarness(options: {
   const clientStream: Stream = ndJsonStream(clientOutput, a2c.readable)
 
   const updates: CapturedUpdate[] = []
+  const sessionUpdates: { sessionId: string; update: CapturedUpdate }[] = []
   const permissionRequests: RequestPermissionRequest[] = []
   const harness: BridgeHarness = {
     ctx,
     adapter,
     updates,
+    sessionUpdates,
     permissionRequests,
     onPermission: () => ({ outcome: { outcome: 'cancelled' } }),
     onSessionUpdateError: undefined,
@@ -202,6 +206,7 @@ export async function makeBridgeHarness(options: {
   const makeClient = (_agent: AcpAgent): Client => ({
     sessionUpdate(params: SessionNotification): Promise<void> {
       updates.push(params.update)
+      sessionUpdates.push({ sessionId: params.sessionId, update: params.update })
       // Let a test force the bridge's notify() error path.
       if (harness.onSessionUpdateError) return Promise.reject(new Error('client update rejected'))
       return Promise.resolve()

+ 12 - 5
packages/acp/tests/load.spec.ts

@@ -120,11 +120,18 @@ describe('acp bridge — session/load replay', () => {
       .rejects.toThrow(/launch directory/)
   })
 
-  it('rejects load when a session already exists (single-session MVP)', async () => {
-    live = await makeBridgeHarness({ storageDir, script: [] })
+  it('allows loading alongside an existing session but rejects re-loading the SAME id', async () => {
+    // Multi-session: a load can coexist with a live session, but loading an id
+    // that is already live is rejected (it is already loaded).
+    live = await makeBridgeHarness({ storageDir, script: [textResponse('one')] })
     await live.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
-    await live.client.newSession({ cwd: process.cwd(), mcpServers: [] })
-    await expect(live.client.loadSession({ sessionId: 'other', cwd: process.cwd(), mcpServers: [] }))
-      .rejects.toThrow(/single session/)
+    const { sessionId } = await live.client.newSession({ cwd: process.cwd(), mcpServers: [] })
+    await live.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'hi' }] })
+    // A different new session coexists.
+    const other = await live.client.newSession({ cwd: process.cwd(), mcpServers: [] })
+    expect(other.sessionId).not.toBe(sessionId)
+    // Re-loading the already-live id is rejected.
+    await expect(live.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] }))
+      .rejects.toThrow(/already loaded/)
   })
 })

+ 128 - 0
packages/acp/tests/multi-session.spec.ts

@@ -0,0 +1,128 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+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, textResponse, type BridgeHarness, type CapturedUpdate } from './harness.ts'
+
+/** Text of the agent_message_chunk updates scoped to one session id. */
+function messageTextFor(updates: { sessionId?: string; update: CapturedUpdate }[], sessionId: string): string {
+  return updates
+    .filter(u => u.sessionId === sessionId && u.update.sessionUpdate === 'agent_message_chunk')
+    .map(u => (u.update.sessionUpdate === 'agent_message_chunk' && u.update.content.type === 'text' ? u.update.content.text : ''))
+    .join('')
+}
+
+describe('acp bridge — RFC 011 multi-session isolation', () => {
+  let storageDir: string
+  let harness: BridgeHarness | undefined
+
+  beforeEach(async () => { storageDir = await mkdtemp(join(tmpdir(), 'acp-multi-')) })
+  afterEach(async () => {
+    if (harness) await harness.dispose()
+    harness = undefined
+    await rm(storageDir, { recursive: true, force: true })
+  })
+
+  it('two sessions stream concurrently without interleaving their updates', async () => {
+    // Each session's prompt answer must arrive only on its own sessionId. The
+    // scripted adapter answers in send order; both prompts run, and the bridge
+    // demuxes every chunk by session id.
+    harness = await makeBridgeHarness({ storageDir, script: [textResponse('answer-A'), textResponse('answer-B')] })
+    await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
+    const a = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
+    const b = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
+
+    const [ra, rb] = await Promise.all([
+      harness.client.prompt({ sessionId: a, prompt: [{ type: 'text', text: 'go A' }] }),
+      harness.client.prompt({ sessionId: b, prompt: [{ type: 'text', text: 'go B' }] }),
+    ])
+    expect(ra.stopReason).toBe('end_turn')
+    expect(rb.stopReason).toBe('end_turn')
+
+    // A's text landed only on A; B's only on B (strict id demux, no interleave).
+    expect(messageTextFor(harness.sessionUpdates, a)).toContain('answer-A')
+    expect(messageTextFor(harness.sessionUpdates, a)).not.toContain('answer-B')
+    expect(messageTextFor(harness.sessionUpdates, b)).toContain('answer-B')
+    expect(messageTextFor(harness.sessionUpdates, b)).not.toContain('answer-A')
+  })
+
+  it('cancel in one session leaves the other session untouched', async () => {
+    // Session A hangs; session B completes normally. Cancelling A settles ONLY
+    // A as cancelled and never disturbs B's stream or result.
+    harness = await makeBridgeHarness({ storageDir, script: ['hang', textResponse('B done')] })
+    await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
+    const a = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
+    const b = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
+
+    const aPromise = harness.client.prompt({ sessionId: a, prompt: [{ type: 'text', text: 'hang A' }] })
+    await new Promise(r => setTimeout(r, 30))
+    await harness.client.cancel({ sessionId: a })
+    expect((await aPromise).stopReason).toBe('cancelled')
+
+    // B runs to completion, unaffected by A's cancel.
+    const rb = await harness.client.prompt({ sessionId: b, prompt: [{ type: 'text', text: 'go B' }] })
+    expect(rb.stopReason).toBe('end_turn')
+    expect(messageTextFor(harness.sessionUpdates, b)).toContain('B done')
+  })
+
+  it('enforces one in-flight prompt PER session independently', async () => {
+    harness = await makeBridgeHarness({ storageDir, script: ['hang', 'hang'] })
+    await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
+    const a = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
+    const b = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
+
+    // One in-flight prompt in EACH session is allowed (independent limits).
+    const aPromise = harness.client.prompt({ sessionId: a, prompt: [{ type: 'text', text: 'one A' }] })
+    const bPromise = harness.client.prompt({ sessionId: b, prompt: [{ type: 'text', text: 'one B' }] })
+    await new Promise(r => setTimeout(r, 30))
+    // A second prompt in A is rejected, but B's in-flight prompt is unaffected.
+    await expect(harness.client.prompt({ sessionId: a, prompt: [{ type: 'text', text: 'two A' }] }))
+      .rejects.toThrow(/already in flight/)
+
+    await harness.client.cancel({ sessionId: a })
+    await harness.client.cancel({ sessionId: b })
+    expect((await aPromise).stopReason).toBe('cancelled')
+    expect((await bPromise).stopReason).toBe('cancelled')
+  })
+
+  it('a cancel for a non-existent session id is a silent no-op (does not touch others)', async () => {
+    harness = await makeBridgeHarness({ storageDir, script: [textResponse('A done')] })
+    await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
+    const a = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
+    await expect(harness.client.cancel({ sessionId: 'ghost' })).resolves.toBeUndefined()
+    // A still works after a cancel for an unknown id.
+    const ra = await harness.client.prompt({ sessionId: a, prompt: [{ type: 'text', text: 'go A' }] })
+    expect(ra.stopReason).toBe('end_turn')
+  })
+
+  it('disposing the whole bridge drains all live sessions to quiescence', async () => {
+    harness = await makeBridgeHarness({ storageDir, script: ['hang', 'hang'] })
+    await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
+    const a = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
+    const b = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
+    const agentA = harness.ctx.agents.get(a)!
+    const agentB = harness.ctx.agents.get(b)!
+
+    // Wait deterministically for BOTH agents to enter `running` (not a fixed
+    // sleep — agent startup latency is unbounded on a loaded worker).
+    const running = (agent: typeof agentA) => agent.status === 'running'
+      ? Promise.resolve()
+      : new Promise<void>((resolve) => {
+        const dispose = harness!.ctx.on('agent/status', (subject, status) => {
+          if (subject === agent && status === 'running') { dispose(); resolve() }
+        })
+      })
+    void harness.client.prompt({ sessionId: a, prompt: [{ type: 'text', text: 'go A' }] }).catch(() => {})
+    void harness.client.prompt({ sessionId: b, prompt: [{ type: 'text', text: 'go B' }] }).catch(() => {})
+    await Promise.all([running(agentA), running(agentB)])
+    expect(agentA.status).toBe('running')
+    expect(agentB.status).toBe('running')
+
+    await harness.ctx.fiber.dispose()
+    // BOTH agents drained (not still running) — teardown reached quiescence
+    // across all sessions, not just one.
+    expect(agentA.status).not.toBe('running')
+    expect(agentB.status).not.toBe('running')
+  })
+})

+ 4 - 0
packages/tool-bash/README.md

@@ -28,6 +28,10 @@ Result text: stdout, then a `[stderr]` section, then status markers — `[timed
 
 `task_id` → SIGTERM→SIGKILL on the task's process group. Killing an already-finished task is a reported no-op; unknown ids are errors.
 
+### Task ownership (cross-session isolation)
+
+The owning agent is recorded per task id at spawn and kept for the lifetime of the loaded plugin instance (it is **not** cleared on completion). `bash_output`/`bash_kill` reject a task owned by a *different* agent with `task <id> belongs to another session` (a task started with no agent — a non-loop caller — has no owner and is open to anyone; a call with no `exec.agent` cannot access an owned task). Task ids are global and predictable, so under multi-session ACP this ownership check is the fence that stops one session's agent from reading or killing another session's background task. (`TODO(tool-bash-owner-hmr)`: an independent HMR reload of this plugin starts a fresh map, so a task spawned before the reload becomes un-owned — acceptable as HMR is dev-only and the session boundary is one user's cooperative editor; a durable fix attaches ownership to the executor/task lifetime.)
+
 ## Background completion notices
 
 When a background task finishes, a short notice is injected into the owning agent's session (`agent.inject()`, source `{kind: 'plugin', plugin: 'tool-bash'}`). Injection is **durable context for the next model request, not a wake-up** — an idle agent stays idle until something sends a message. That's why the tool descriptions tell the model to poll with `bash_output`.

+ 49 - 8
packages/tool-bash/src/index.ts

@@ -11,6 +11,23 @@
  * message, which is why the tool descriptions tell the model to poll with
  * `bash_output`.
  *
+ * Task ownership: the owning agent is recorded per task id at spawn and kept
+ * for the lifetime of THIS plugin instance (it is NOT cleared on task
+ * completion — a finished task must stay un-readable / un-killable by a
+ * different agent). `bash_output`/`bash_kill` reject a task owned by a DIFFERENT
+ * agent (a task with no recorded owner is open to anyone). Task ids are global
+ * and predictable (`bash-1`, …); under multi-session ACP (RFC 011) this
+ * ownership check is the fence that stops one session's agent from reading or
+ * killing another session's background task.
+ *
+ * TODO(tool-bash-owner-hmr): the ownership map is per-plugin-instance, so an
+ * independent HMR reload of `tool-bash` (without reloading `dsh-bash`) starts a
+ * fresh map and a task spawned before the reload becomes un-owned (open to any
+ * caller). This is acceptable today — HMR is dev-only, the ACP session boundary
+ * is one user's cooperative editor (not an adversarial trust boundary), and the
+ * executor's own disposal kills its tasks — but a durable fix would attach
+ * ownership to the executor/task lifetime via a `dsh-bash` seam.
+ *
  * TODO(permissions): commands run with the executor's full authority. The
  * permission/sandbox seam is the `tools/execute` waterfall (veto/ask) plus
  * sandboxing `BashExecutor` implementations — see docs/architecture.md
@@ -116,12 +133,33 @@ function statusLine(task: BashTask): string {
 }
 
 export function apply(ctx: Context): void {
+  // Owning agent per background task id, recorded at spawn. Kept for the
+  // lifetime of THIS plugin instance (NOT cleared on completion): a completed
+  // task must stay un-readable / un-killable by a DIFFERENT agent, so the
+  // ownership record outlives the task. Under multi-session ACP (RFC 011) this
+  // is the isolation fence — one session's agent must never read or kill
+  // another session's background task. A task with no recorded owner (started by
+  // a non-loop caller, `exec.agent` absent) is unowned and accessible to anyone.
+  // An independent `tool-bash` HMR reload resets this map — see the
+  // TODO(tool-bash-owner-hmr) note in the module doc.
+  const taskOwner = new Map<string, Agent>()
+
+  /**
+   * Authorize a `bash_output`/`bash_kill` call against a task's owner. Rejects
+   * when the task has a recorded owner and the caller is not that exact agent —
+   * including the conservative no-agent case (`exec.agent` absent cannot prove
+   * ownership of an owned task). An unowned task (no record) is allowed.
+   */
+  const assertTaskAccess = (taskId: string, exec: { agent?: Agent }): void => {
+    const owner = taskOwner.get(taskId)
+    if (owner !== undefined && owner !== exec.agent) {
+      throw new Error(`task ${taskId} belongs to another session`)
+    }
+  }
+
   // Background completion → inject a notice into the owning agent's session.
-  // Tracks the agent per task id; entries drop once notified.
-  const owners = new Map<string, Agent>()
   ctx.bash.onTaskDone((task) => {
-    const agent = owners.get(task.id)
-    owners.delete(task.id)
+    const agent = taskOwner.get(task.id)
     if (!agent) return
     try {
       agent.inject(
@@ -172,7 +210,7 @@ export function apply(ctx: Context): void {
       }
       if (args.run_in_background === true) {
         const task = ctx.bash.start(ctx.bash.resolve(request))
-        if (exec.agent) owners.set(task.id, exec.agent)
+        if (exec.agent) taskOwner.set(task.id, exec.agent)
         return [{ type: 'text', text: `started background task ${task.id}` }]
       }
       const result = await ctx.bash.run(ctx.bash.resolve(request))
@@ -191,8 +229,10 @@ export function apply(ctx: Context): void {
     },
     // execute is synchronous (registry reads + string shaping) but the
     // ToolDefinition contract wants a Promise — hence resolve(), not async.
-    execute(args) {
-      const read = ctx.bash.readOutput(validateTaskId(args.task_id))
+    execute(args, exec) {
+      const id = validateTaskId(args.task_id)
+      assertTaskAccess(id, exec)
+      const read = ctx.bash.readOutput(id)
       let text = read.delta.length > 0 ? read.delta : '(no new output)'
       if (read.lossy) {
         const paths = [read.stdoutSpillPath, read.stderrSpillPath].filter((p): p is string => p !== undefined)
@@ -209,8 +249,9 @@ export function apply(ctx: Context): void {
     parameters: {
       task_id: { type: 'string', required: true, description: 'Task id returned by the bash tool.' },
     },
-    execute(args) {
+    execute(args, exec) {
       const id = validateTaskId(args.task_id)
+      assertTaskAccess(id, exec)
       const killed = ctx.bash.kill(id)
       return Promise.resolve([{
         type: 'text',

+ 100 - 0
packages/tool-bash/tests/tools.spec.ts

@@ -338,6 +338,106 @@ describe('background tools', () => {
   })
 })
 
+describe('background task ownership (cross-session isolation)', () => {
+  /** Run a tool on behalf of a specific agent (sets exec.agent). */
+  function callAs(ctx: Context, agent: import('@deepseek-ai/dsh-agent').Agent | undefined, name: string, args: unknown) {
+    return ctx.tools.execute({ callId: CallId(`own-${++callCounter}`), name, arguments: args, ...agent ? { agent } : {} })
+  }
+  // Distinct identities — ownership is by agent object identity, not id.
+  const fakeAgent = () => ({ inject: () => undefined }) as unknown as import('@deepseek-ai/dsh-agent').Agent
+
+  it('rejects bash_output/bash_kill for a task owned by a DIFFERENT agent', async () => {
+    const ctx = await setup()
+    const a = fakeAgent()
+    const b = fakeAgent()
+    // Agent A starts a long-running background task.
+    const started = await callAs(ctx, a, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true })
+    const id = /task (bash-\d+)/.exec(text(started))![1]!
+
+    // Agent B cannot read or kill A's task.
+    const readByB = await callAs(ctx, b, 'bash_output', { task_id: id })
+    expect(readByB.isError).toBe(true)
+    expect(text(readByB)).toMatch(/belongs to another session/)
+    const killByB = await callAs(ctx, b, 'bash_kill', { task_id: id })
+    expect(killByB.isError).toBe(true)
+    expect(text(killByB)).toMatch(/belongs to another session/)
+
+    // The task is still running (B's kill did nothing) — A can still kill it.
+    const killByA = await callAs(ctx, a, 'bash_kill', { task_id: id })
+    expect(killByA.isError).toBe(false)
+    expect(text(killByA)).toBe(`killed background task ${id}`)
+  })
+
+  it('the no-agent (non-loop) caller cannot access an owned task', async () => {
+    const ctx = await setup()
+    const a = fakeAgent()
+    const started = await callAs(ctx, a, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true })
+    const id = /task (bash-\d+)/.exec(text(started))![1]!
+    // A call with no exec.agent cannot prove ownership of an owned task.
+    const read = await callAs(ctx, undefined, 'bash_output', { task_id: id })
+    expect(read.isError).toBe(true)
+    expect(text(read)).toMatch(/belongs to another session/)
+    await callAs(ctx, a, 'bash_kill', { task_id: id }) // cleanup
+  })
+
+  it('an UNOWNED task (started with no agent) is accessible to anyone', async () => {
+    const ctx = await setup()
+    // Started by a non-loop caller (no exec.agent) → no recorded owner.
+    const started = await callAs(ctx, undefined, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true })
+    const id = /task (bash-\d+)/.exec(text(started))![1]!
+    // Any agent (and the no-agent caller) may read/kill it.
+    const read = await callAs(ctx, fakeAgent(), 'bash_output', { task_id: id })
+    expect(read.isError).toBe(false)
+    const killed = await callAs(ctx, undefined, 'bash_kill', { task_id: id })
+    expect(killed.isError).toBe(false)
+  })
+
+  it('the owner can still access its task AFTER it completes (owner record persists)', async () => {
+    const ctx = await setup()
+    const a = fakeAgent()
+    const b = fakeAgent()
+    const started = await callAs(ctx, a, 'bash', { command: 'echo done', description: 'bg', run_in_background: true })
+    const id = /task (bash-\d+)/.exec(text(started))![1]!
+    await ctx.bash.get(id)!.done
+    // Completion does NOT clear ownership: B is still rejected, A still allowed.
+    const readByB = await callAs(ctx, b, 'bash_output', { task_id: id })
+    expect(readByB.isError).toBe(true)
+    expect(text(readByB)).toMatch(/belongs to another session/)
+    const readByA = await callAs(ctx, a, 'bash_output', { task_id: id })
+    expect(readByA.isError).toBe(false)
+  })
+
+  it('documents the HMR caveat: an independent tool-bash reload resets ownership', async () => {
+    // The ownership map is per-plugin-instance (TODO(tool-bash-owner-hmr)). When
+    // ONLY tool-bash is reloaded (bash/executor + task survive), the new instance
+    // has an empty map, so the previously-owned task becomes unowned (open). This
+    // test pins that documented behavior — a regression here (e.g. an accidental
+    // global map) would change it.
+    const ctx = new Context()
+    await ctx.plugin(SystemPrompt)
+    await ctx.plugin(ToolRegistry)
+    await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
+    ;(ctx.bash as LocalBashExecutor).internals = { spillDir, graceMs: 200 }
+    const fiber = await ctx.plugin(ToolBash)
+
+    const a = fakeAgent()
+    const b = fakeAgent()
+    const started = await callAs(ctx, a, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true })
+    const id = /task (bash-\d+)/.exec(text(started))![1]!
+    // Before reload: B is rejected (A owns it).
+    expect((await callAs(ctx, b, 'bash_output', { task_id: id })).isError).toBe(true)
+
+    // Reload ONLY tool-bash; the executor and its running task survive.
+    await fiber.dispose()
+    await ctx.plugin(ToolBash)
+    expect(ctx.bash.get(id)?.status).toBe('running')
+
+    // After reload the fresh map has no owner → B can now access it (the caveat).
+    expect((await callAs(ctx, b, 'bash_output', { task_id: id })).isError).toBe(false)
+    await callAs(ctx, b, 'bash_kill', { task_id: id }) // cleanup
+  })
+})
+
 describe('renderResult', () => {
   const base = {
     exitCode: 0 as number | null,