浏览代码

Merge latest master human inbox controls into V3 integration

Preserve the V3 PTC scenario while enabling the current subagent human-steering fixture environment. All product changes are inherited from master; no V3 migration semantics are altered.
Tianyi Cui 3 天之前
父节点
当前提交
fffd39b101
共有 70 个文件被更改,包括 2324 次插入1080 次删除
  1. 2 2
      .agents/notes/implemented/bug-fix/2026-08-17-subagent-message-settlement-ordering.i18n.yaml
  2. 1 1
      .agents/notes/implemented/bug-fix/2026-08-17-subagent-message-settlement-ordering.md
  3. 1 1
      .agents/notes/implemented/bug-fix/2026-08-17-subagent-message-settlement-ordering.zh.md
  4. 2 2
      .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml
  5. 5 5
      .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md
  6. 5 5
      .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md
  7. 2 2
      .agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml
  8. 14 14
      .agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md
  9. 14 14
      .agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md
  10. 2 2
      .agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.i18n.yaml
  11. 2 2
      .agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.md
  12. 2 2
      .agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.zh.md
  13. 6 0
      .agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.i18n.yaml
  14. 51 0
      .agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.md
  15. 51 0
      .agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.zh.md
  16. 4 4
      apps/web/tests/steering.e2e.ts
  17. 35 11
      apps/web/tests/subagent-interrupt-ui.e2e.ts
  18. 2 0
      apps/web/tests/subagent-interrupt.e2e.ts
  19. 1 1
      docs/event-producer-consumer.i18n.yaml
  20. 4 4
      docs/event-producer-consumer.md
  21. 2 2
      docs/module-graph.i18n.yaml
  22. 2 1
      docs/module-graph.md
  23. 2 1
      docs/module-graph.zh.md
  24. 2 2
      docs/subsystems/subagent.i18n.yaml
  25. 11 8
      docs/subsystems/subagent.md
  26. 11 8
      docs/subsystems/subagent.zh.md
  27. 2 2
      packages/api/session-controller/README.i18n.yaml
  28. 1 1
      packages/api/session-controller/README.md
  29. 1 1
      packages/api/session-controller/README.zh.md
  30. 2 0
      packages/api/session-controller/package.json
  31. 1 1
      packages/api/session-controller/src/client/contract/session.ts
  32. 1 0
      packages/api/session-controller/src/client/sessions/session.ts
  33. 27 12
      packages/api/session-controller/src/commands.ts
  34. 136 6
      packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts
  35. 1 0
      packages/api/session-controller/tests/manager.client.spec.ts
  36. 12 0
      packages/api/session-controller/tests/session.client.spec.ts
  37. 1 0
      packages/api/session-controller/tsconfig.host.json
  38. 2 2
      packages/client/ui-conversation/README.i18n.yaml
  39. 1 2
      packages/client/ui-conversation/README.md
  40. 1 2
      packages/client/ui-conversation/README.zh.md
  41. 4 4
      packages/client/ui-conversation/src/client/input/hub.ts
  42. 1 1
      packages/client/ui-conversation/src/client/queue/QueueDock.tsx
  43. 2 2
      packages/client/ui-conversation/src/client/service.ts
  44. 5 4
      packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
  45. 19 14
      packages/client/ui-conversation/tests/input-bar.client.spec.tsx
  46. 25 2
      packages/client/ui-conversation/tests/queue-dock.client.spec.tsx
  47. 2 2
      packages/client/ui-conversation/tests/service-orchestration.client.spec.ts
  48. 3 3
      packages/extensions/tool-cordis/src/api-catalog.ts
  49. 2 2
      packages/subagent/subagent/README.i18n.yaml
  50. 9 5
      packages/subagent/subagent/README.md
  51. 9 5
      packages/subagent/subagent/README.zh.md
  52. 854 0
      packages/subagent/subagent/src/continuation-activation.ts
  53. 154 0
      packages/subagent/subagent/src/continuation-messages.ts
  54. 91 759
      packages/subagent/subagent/src/continuation.ts
  55. 2 0
      packages/subagent/subagent/src/control-types.ts
  56. 1 0
      packages/subagent/subagent/src/control.ts
  57. 70 0
      packages/subagent/subagent/src/inbox.ts
  58. 18 21
      packages/subagent/subagent/src/index.ts
  59. 2 4
      packages/subagent/subagent/src/internal.ts
  60. 45 1
      packages/subagent/subagent/src/types.ts
  61. 30 0
      packages/subagent/subagent/tests/continuation-internals.ts
  62. 474 115
      packages/subagent/subagent/tests/continuation.spec.ts
  63. 20 1
      packages/subagent/subagent/tests/control.spec.ts
  64. 5 2
      packages/subagent/subagent/tests/list-children.spec.ts
  65. 25 0
      packages/test-support/session-snapshot/tests/fixtures/subagent-durability-failure.ts
  66. 3 0
      pnpm-lock.yaml
  67. 5 5
      scripts/type-equiv.manifest.json
  68. 3 0
      snapshots/sdk/sdk.snapshot.ts
  69. 7 5
      snapshots/sdk/subagent-continuable/session.1.v2.jsonl
  70. 9 0
      snapshots/web/subagent-interrupt/offline-composer.expected.md

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-08-17-subagent-message-settlement-ordering.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-17-subagent-message-settlement-ordering.md
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-17-subagent-message-settlement-ordering.md
-2026-08-17-subagent-message-settlement-ordering.md: cdc996643c84c5f50a3bd1836e82645660dc8c57
-2026-08-17-subagent-message-settlement-ordering.zh.md: 1143da1560e4969dcc4f6a0c6d5ca18060b56191
+2026-08-17-subagent-message-settlement-ordering.md: 7462a670766664745e46204dcb01e578b4f86219
+2026-08-17-subagent-message-settlement-ordering.zh.md: bba8fe0e7c543982176def4dbcc6f94dda204339

+ 1 - 1
.agents/notes/implemented/bug-fix/2026-08-17-subagent-message-settlement-ordering.md

@@ -14,7 +14,7 @@ The child instruction says to send a finding whenever it changes what the parent
 
 
 Every model-authored adjacent-Agent message uses fixed Steer delivery through `SubagentRuntime.sendMessage()`. A running parent reads the child message at its nearest safe step boundary and an idle parent starts a turn. There is no quiet or next-turn model delivery option.
 Every model-authored adjacent-Agent message uses fixed Steer delivery through `SubagentRuntime.sendMessage()`. A running parent reads the child message at its nearest safe step boundary and an idle parent starts a turn. There is no quiet or next-turn model delivery option.
 
 
-The continuation manager retains `sendWaking()` and `admitWaking()` around messages delivered to resident continuable parents. Their purpose is waking-send admission accounting: the receiving Activation remains live between synchronous inbox insertion and the microtask that observes the wake.
+The continuation manager retains `sendWaking()` around messages delivered to resident continuable parents and routes the synchronous send through the parent's private `SubagentInbox`. The wrapper accepts the send before its closing promise is installed or rejects it afterwards, and an accepted attempt renews the Activation's wake generation before returning. The receiving Activation therefore cannot settle over an accepted waking send.
 
 
 ### Ordering across parent states
 ### Ordering across parent states
 
 

+ 1 - 1
.agents/notes/implemented/bug-fix/2026-08-17-subagent-message-settlement-ordering.zh.md

@@ -14,7 +14,7 @@ child 指令要求在发现会改变 parent 下一步动作时发送该发现。
 
 
 每条模型编写的相邻 Agent 消息都通过 `SubagentRuntime.sendMessage()` 使用固定 Steer 投递。运行中的 parent 在最近安全 step 边界读取 child 消息,空闲 parent 则启动一个轮次。模型没有静默或 next-turn 投递选项。
 每条模型编写的相邻 Agent 消息都通过 `SubagentRuntime.sendMessage()` 使用固定 Steer 投递。运行中的 parent 在最近安全 step 边界读取 child 消息,空闲 parent 则启动一个轮次。模型没有静默或 next-turn 投递选项。
 
 
-继续执行管理器在投递到驻留可继续 parent 的消息周围保留 `sendWaking()` 与 `admitWaking()`。它们负责唤醒发送准入记账:接收方 Activation 会在同步 inbox 插入与观察到唤醒的微任务之间保持在线
+继续执行管理器在投递到驻留可继续 parent 的消息周围保留 `sendWaking()`,并通过 parent 的私有 `SubagentInbox` 执行同步发送。包装层会在安装 closing promise 前接受发送,并在安装后拒绝发送;被接受的尝试会在返回前更新 Activation 的 wake generation。因此,接收方 Activation 不会越过一条已接受的唤醒发送完成结算
 
 
 ### 不同 parent 状态下的顺序
 ### 不同 parent 状态下的顺序
 
 

+ 2 - 2
.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md
-2026-07-27-web-subagent-conversations.md: 28e06d1db9103de53e6e2fb266a03e168428d0d2
-2026-07-27-web-subagent-conversations.zh.md: e93abe89e855d688d822bfd20fd1644f24bdf3e9
+2026-07-27-web-subagent-conversations.md: d0713731de86df639a426ee56c39857c50af6bc7
+2026-07-27-web-subagent-conversations.zh.md: 0f4f6467484ee1caa3cdf28f44e016ff674ac1dc

+ 5 - 5
.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md

@@ -16,11 +16,11 @@ The UI must also preserve the membership, modes, and diagnostics of the [durable
 
 
 The Web product exposes the selected session's direct session-backed subagents from the current-title lineage region in the header. Users can lazily expand descendant catalogs and open either mode in the existing conversation region. A one-shot child is permanently read-only. A continuable child accepts human follow-ups only while its exact direct-parent Agent is live; otherwise its persisted transcript remains readable with a recovery explanation.
 The Web product exposes the selected session's direct session-backed subagents from the current-title lineage region in the header. Users can lazily expand descendant catalogs and open either mode in the existing conversation region. A one-shot child is permanently read-only. A continuable child accepts human follow-ups only while its exact direct-parent Agent is live; otherwise its persisted transcript remains readable with a recovery explanation.
 
 
-Every opened child carries a catalog-derived address `{ parentSessionId, childSessionId, mode }`. The mode-bearing address, not lineage or the coarse origin marker, selects dedicated history and prompt transports. History reads the persisted session without activation. A continuable prompt calls `ctx.subagents.followup()` and succeeds at inbox acceptance with `{ messageId }`; it does not steer an open turn, expose an Activation, wait for completion, or return an outcome.
+Every opened child carries a catalog-derived address `{ parentSessionId, childSessionId, mode }`. The mode-bearing address, not lineage or the coarse origin marker, selects dedicated history and prompt transports. History reads the persisted session without activation. A continuable prompt carries Queue or Steer delivery through `subagent.prompt` and succeeds at inbox acceptance with `{ messageId }`; it does not expose an Activation, wait for completion, or return an outcome. Adjacent-Agent model messages use the separately owned fixed-Steer operation.
 
 
 The generic Host domain preserves the same ownership boundary. `session.history` and the source side of `session.fork` read an attached Session or inspect persistence without acquiring an Agent; history folds cold projection values from that exact inspected prefix, while a fork publishes an ordinary independent session. Generic Agent-bound session, command, and goal routes return `agent-busy` for session-backed subagents, as do explicit-id `session.create` adoption and attached-only queue controls. The denial classifier accepts the coarse `origin` marker, a `subagent/descriptor` in the session's own suffix, or exact live runtime ownership by the parent; these signals only prevent generic ownership and never replace catalog mode or direct-parent authorization.
 The generic Host domain preserves the same ownership boundary. `session.history` and the source side of `session.fork` read an attached Session or inspect persistence without acquiring an Agent; history folds cold projection values from that exact inspected prefix, while a fork publishes an ordinary independent session. Generic Agent-bound session, command, and goal routes return `agent-busy` for session-backed subagents, as do explicit-id `session.create` adoption and attached-only queue controls. The denial classifier accepts the coarse `origin` marker, a `subagent/descriptor` in the session's own suffix, or exact live runtime ownership by the parent; these signals only prevent generic ownership and never replace catalog mode or direct-parent authorization.
 
 
-Stopping an addressed child never falls through to `session.cancel`. `SubagentRuntime.followup()` owns admission only until inbox acceptance and grants no cancellation handle; a running continuable child is stopped through the dedicated `subagent.interrupt` route under the [current-turn interrupt contract](2026-08-06-continuable-subagent-interrupt.md), which parks pending work instead of discarding it. One-shot children remain uncancellable from the Web.
+Stopping an addressed child never falls through to `session.cancel`. Browser prompt delivery owns admission only until inbox acceptance and grants no cancellation handle; a running continuable child is stopped through the dedicated `subagent.interrupt` route under the [current-turn interrupt contract](2026-08-06-continuable-subagent-interrupt.md), which parks pending work instead of discarding it. One-shot children remain uncancellable from the Web.
 
 
 This decision covers Web discovery, transcript viewing, and parent-authorized human continuation. It does not make a subagent independently user-owned; that product remains [interactive side sessions](../../proposed/feature/2026-07-08-interactive-side-sessions.md).
 This decision covers Web discovery, transcript viewing, and parent-authorized human continuation. It does not make a subagent independently user-owned; that product remains [interactive side sessions](../../proposed/feature/2026-07-08-interactive-side-sessions.md).
 
 
@@ -45,7 +45,7 @@ Healthy rows reuse the standard session projections retained in the list mirror.
 
 
 Selecting a row records its exact address before opening the resident client `Session`. History pagination, event folding, tool render intents, titles, and live mux reconciliation reuse the ordinary conversation machinery. Breadcrumbs follow parent links only through `origin: 'subagent'` rows, include the first ordinary owner, and keep ordinary forks single-level. Each subagent breadcrumb gets its direct-parent sibling catalog and uses that catalog's label when available. Forking an addressed subagent creates an ordinary fork with direct source lineage and attaches it to the nearest workspace-owning ancestor. The catalog is an ARIA tree with lazy ArrowRight/ArrowLeft disclosure, linear ArrowUp/ArrowDown navigation, Home/End, Escape, and focus restoration.
 Selecting a row records its exact address before opening the resident client `Session`. History pagination, event folding, tool render intents, titles, and live mux reconciliation reuse the ordinary conversation machinery. Breadcrumbs follow parent links only through `origin: 'subagent'` rows, include the first ordinary owner, and keep ordinary forks single-level. Each subagent breadcrumb gets its direct-parent sibling catalog and uses that catalog's label when available. Forking an addressed subagent creates an ordinary fork with direct source lineage and attaches it to the nearest workspace-owning ancestor. The catalog is an ARIA tree with lazy ArrowRight/ArrowLeft disclosure, linear ArrowUp/ArrowDown navigation, Home/End, Escape, and focus restoration.
 
 
-A one-shot row always replaces the composer with copy explaining that the execution record is read-only. A continuable row does so only while `parentAvailable` is false and the child is not running; a running parent-offline child keeps the ordinary composer with its input and Send action disabled so independent Stop stays reachable, and the read-only takeover returns once it stops. With a live parent, Enter and Send admit another FIFO turn even while the child runs, while independent Stop routes through `subagent.interrupt` ([interrupt contract](2026-08-06-continuable-subagent-interrupt.md)). Prompt failures retain the draft through the ordinary error behavior.
+A one-shot row always replaces the composer with copy explaining that the execution record is read-only. A continuable row does so only while `parentAvailable` is false and the child is not running; a running parent-offline child keeps the ordinary composer with its input and Send action disabled so independent Stop and live QueueDock controls stay reachable, and the read-only takeover returns once it stops. With a live parent, the ordinary Enter/Cmd+Enter preference selects Queue or best-effort Steer even while the child runs. QueueDock Edit, Remove, and Steer remain available for a live continuable child even when its parent is offline, while independent Stop routes through `subagent.interrupt` ([interrupt contract](2026-08-06-continuable-subagent-interrupt.md)). Prompt failures retain the draft through the ordinary error behavior.
 
 
 Agent-bound auxiliary controls are unavailable in addressed child views. In particular, the model selector and `/model` contribution do not call ordinary `session.models` or `session.selectModel`; the Host also rejects any accidental call instead of activating persisted child history outside the direct-parent continuation path.
 Agent-bound auxiliary controls are unavailable in addressed child views. In particular, the model selector and `/model` contribution do not call ordinary `session.models` or `session.selectModel`; the Host also rejects any accidental call instead of activating persisted child history outside the direct-parent continuation path.
 
 
@@ -55,13 +55,13 @@ Agent-bound auxiliary controls are unavailable in addressed child views. In part
 
 
 - `subagent.list` takes `parentSessionId`, calls `ctx.subagents.listChildren(parentSessionId, signal)`, returns the complete ordered entries with each healthy row's boolean `hasChildren` snapshot, replaces each healthy row's corpus activity with whether its exact Agent driver is running, and includes whether the exact parent currently resolves from `ctx.agents`.
 - `subagent.list` takes `parentSessionId`, calls `ctx.subagents.listChildren(parentSessionId, signal)`, returns the complete ordered entries with each healthy row's boolean `hasChildren` snapshot, replaces each healthy row's corpus activity with whether its exact Agent driver is running, and includes whether the exact parent currently resolves from `ctx.agents`.
 - `subagent.history` takes the full mode-bearing address plus ordinary page arguments. It verifies the child and mode against the direct catalog, reads through `ctx.sessionQuery.readSession()`, rechecks direct lineage, and returns the ordinary raw-event, render-intent, pagination, and host-computed session-projection baseline without publishing an Agent.
 - `subagent.history` takes the full mode-bearing address plus ordinary page arguments. It verifies the child and mode against the direct catalog, reads through `ctx.sessionQuery.readSession()`, rechecks direct lineage, and returns the ordinary raw-event, render-intent, pagination, and host-computed session-projection baseline without publishing an Agent.
-- `subagent.prompt` accepts only a `mode: 'continuable'` address and upload-shaped `PromptContentPart[]`; the Host admits and persists image parts into durable references before delivery ([image delivery](../../archived/bug-fix/2026-08-27-steer-followup-image-delivery.md)). It requires the exact live parent, revalidates the catalog address, calls `ctx.subagents.followup(parent, childId, content, { source, signal })`, and returns the accepted `MessageId`.
+- `subagent.prompt` accepts only a `mode: 'continuable'` address, `delivery: 'queue' | 'steer'`, and upload-shaped `PromptContentPart[]`; the Host admits and persists image parts into durable references before delivery ([image delivery](../../archived/bug-fix/2026-08-27-steer-followup-image-delivery.md)). It requires the exact live parent, revalidates the catalog address, uses the continuation manager's shared human-delivery admission, and returns the accepted `MessageId`.
 
 
 The gateway maps missing parent, missing or diagnostic catalog entries, not-resumable and unauthorized children, request cancellation, image admission and image-capability refusals (`subagent/attachment-invalid`), and temporarily unavailable continuation admission to typed RPC errors. It does not expose descriptor or provider details. A list/prompt race is normal: the prompt result, not the earlier availability or activity snapshot, is authoritative.
 The gateway maps missing parent, missing or diagnostic catalog entries, not-resumable and unauthorized children, request cancellation, image admission and image-capability refusals (`subagent/attachment-invalid`), and temporarily unavailable continuation admission to typed RPC errors. It does not expose descriptor or provider details. A list/prompt race is normal: the prompt result, not the earlier availability or activity snapshot, is authoritative.
 
 
 Viewing persisted history creates no mux subscription by itself. When a follow-up materializes a cold child Activation, the existing Host and mux streams publish its lifecycle and events. Reconnect rebuilds the addressed window through `subagent.history`.
 Viewing persisted history creates no mux subscription by itself. When a follow-up materializes a cold child Activation, the existing Host and mux streams publish its lifecycle and events. Reconnect rebuilds the addressed window through `subagent.history`.
 
 
-The ordinary `session.history` route is likewise observation-only for both ordinary and subagent sessions, but it does not carry the catalog address or grant continuation authority. Every ordinary route that needs an Agent resolves through the shared ownership fence before cold resume; `session.cancel` and `session.updateQueue` apply the same check directly because they intentionally query only attached Agents.
+The ordinary `session.history` route is likewise observation-only for both ordinary and subagent sessions, but it does not carry the catalog address or grant continuation authority. Every ordinary route that needs an Agent resolves through the shared ownership fence before cold resume; `session.cancel` retains that fence. `session.updateQueue` has one target-local exception for a live child whose current projected identity is continuable and comes from its own non-seed suffix; one-shot, missing, unknown, corrupt, seed-only, or cold children remain fenced.
 
 
 The adapter stays behind the generated Remote namespace; `dsh-host-webserver` remains a carrier. Browser code imports the contract through the existing connection package and never reaches host `ctx`, preserving the [archived GUI RPC layering decision](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md).
 The adapter stays behind the generated Remote namespace; `dsh-host-webserver` remains a carrier. Browser code imports the contract through the existing connection package and never reaches host `ctx`, preserving the [archived GUI RPC layering decision](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md).
 
 

+ 5 - 5
.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md

@@ -16,11 +16,11 @@ UI 还必须保留[持久化目录](../../archived/feature/2026-07-22-durable-su
 
 
 Web 产品通过页头的当前 title 谱系区域公开选中会话中由会话支撑的直接 subagent。用户可以懒加载展开后代目录,并在现有对话区域中打开任一 mode。one-shot child 永久只读。可继续 child 只有在其确切直接 parent agent 存活时才接受用户后续消息;否则,其持久化 transcript 仍然可读,并附带恢复说明。
 Web 产品通过页头的当前 title 谱系区域公开选中会话中由会话支撑的直接 subagent。用户可以懒加载展开后代目录,并在现有对话区域中打开任一 mode。one-shot child 永久只读。可继续 child 只有在其确切直接 parent agent 存活时才接受用户后续消息;否则,其持久化 transcript 仍然可读,并附带恢复说明。
 
 
-每个打开的 child 都携带目录派生地址 `{ parentSessionId, childSessionId, mode }`。选择专用历史与提示词传输的是包含 mode 的地址,而不是谱系或粗粒度 origin 标记。历史操作会从持久化存储读取会话,而不触发激活。可继续提示词操作会调用 `ctx.subagents.followup()`,并在 inbox 接受消息时以 `{ messageId }` 成功返回;它不会对进行中的轮次执行 steering(中途引导)、公开 Activation、等待完成或返回结果。
+每个打开的 child 都携带目录派生地址 `{ parentSessionId, childSessionId, mode }`。选择专用历史与提示词传输的是包含 mode 的地址,而不是谱系或粗粒度 origin 标记。历史操作会从持久化存储读取会话,而不触发激活。可继续提示词通过 `subagent.prompt` 携带 Queue 或 Steer 投递,并在 inbox 接受消息时以 `{ messageId }` 成功返回;它不会公开 Activation、等待完成或返回结果。相邻 Agent 的模型消息使用单独拥有的固定 Steer 操作。
 
 
 通用 Host 领域遵守同一所有权边界。`session.history` 与 `session.fork` 的源端会读取已附加 Session 或检查持久化存储,而不获取 Agent;history 从所检查的确切前缀归并冷态投影值,fork 则发布一个普通的独立会话。绑定到 Agent 的通用会话、命令与目标路由会对由会话支撑的 subagent 返回 `agent-busy`;显式 id 的 `session.create` 接纳与仅针对已附加会话的队列控件亦然。拒绝分类器接受粗粒度 `origin` 标记、会话自身后缀中的 `subagent/descriptor`,或 parent 对其确切的存活运行时所有权;这些信号只会阻止通用路径取得所有权,绝不取代目录 mode 或直接 parent 授权。
 通用 Host 领域遵守同一所有权边界。`session.history` 与 `session.fork` 的源端会读取已附加 Session 或检查持久化存储,而不获取 Agent;history 从所检查的确切前缀归并冷态投影值,fork 则发布一个普通的独立会话。绑定到 Agent 的通用会话、命令与目标路由会对由会话支撑的 subagent 返回 `agent-busy`;显式 id 的 `session.create` 接纳与仅针对已附加会话的队列控件亦然。拒绝分类器接受粗粒度 `origin` 标记、会话自身后缀中的 `subagent/descriptor`,或 parent 对其确切的存活运行时所有权;这些信号只会阻止通用路径取得所有权,绝不取代目录 mode 或直接 parent 授权。
 
 
-停止一个已寻址 child 绝不回退到 `session.cancel`。`SubagentRuntime.followup()` 只负责消息被 inbox 接受前的准入,不授予取消句柄;正在运行的可继续 child 通过专用的 `subagent.interrupt` 路由停止,遵循[当前轮次中断约定](2026-08-06-continuable-subagent-interrupt.zh.md),该约定会停放并保留待处理工作,而不是将其丢弃。one-shot child 在 Web 端仍不可取消。
+停止一个已寻址 child 绝不回退到 `session.cancel`。浏览器 prompt 投递只负责消息被 inbox 接受前的准入,不授予取消句柄;正在运行的可继续 child 通过专用的 `subagent.interrupt` 路由停止,遵循[当前轮次中断约定](2026-08-06-continuable-subagent-interrupt.zh.md),该约定会停放并保留待处理工作,而不是将其丢弃。one-shot child 在 Web 端仍不可取消。
 
 
 本决策涵盖 Web 端发现、transcript 查看与经 parent 授权的用户继续交互。它不会让 subagent 成为用户独立所有的对象;这类产品仍然属于[交互式 side session](../../proposed/feature/2026-07-08-interactive-side-sessions.zh.md)。
 本决策涵盖 Web 端发现、transcript 查看与经 parent 授权的用户继续交互。它不会让 subagent 成为用户独立所有的对象;这类产品仍然属于[交互式 side session](../../proposed/feature/2026-07-08-interactive-side-sessions.zh.md)。
 
 
@@ -45,7 +45,7 @@ Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5
 
 
 选择一行后,系统会先记录其确切地址,再打开常驻客户端 `Session`。历史分页、事件 fold、工具渲染意图、title 与实时 mux 归并都会复用普通对话机制。面包屑导航只会沿 `origin: 'subagent'` 行的父链接逐级回溯,包含第一个普通 owner,并让普通 fork 保持单层。每一级 subagent 面包屑都会获得其直接 parent 的 sibling 目录,并在目录可用时采用其中的 label。从已寻址 subagent 创建 fork 时,会生成具有直接源谱系的普通 fork,并将其附加到最近拥有 Workspace 的祖先。目录是一棵 ARIA 树,支持懒加载式 ArrowRight/ArrowLeft 展开与折叠、线性 ArrowUp/ArrowDown 导航、Home/End、Escape 以及焦点恢复。
 选择一行后,系统会先记录其确切地址,再打开常驻客户端 `Session`。历史分页、事件 fold、工具渲染意图、title 与实时 mux 归并都会复用普通对话机制。面包屑导航只会沿 `origin: 'subagent'` 行的父链接逐级回溯,包含第一个普通 owner,并让普通 fork 保持单层。每一级 subagent 面包屑都会获得其直接 parent 的 sibling 目录,并在目录可用时采用其中的 label。从已寻址 subagent 创建 fork 时,会生成具有直接源谱系的普通 fork,并将其附加到最近拥有 Workspace 的祖先。目录是一棵 ARIA 树,支持懒加载式 ArrowRight/ArrowLeft 展开与折叠、线性 ArrowUp/ArrowDown 导航、Home/End、Escape 以及焦点恢复。
 
 
-one-shot 行始终会用文案替代输入框,说明执行记录为只读。可继续行仅在 `parentAvailable` 为 false 且 child 未在运行时如此;parent 离线但仍在运行的 child 保留普通输入框,并禁用其输入区和 Send 操作,让独立的 Stop 保持可达,停止后只读替代恢复。parent 在线时,即使 child 正在运行,Enter 和 Send 也会准入另一个 FIFO 轮次,而独立的 Stop 经由 `subagent.interrupt` 路由([中断约定](2026-08-06-continuable-subagent-interrupt.zh.md))。提示词失败会通过普通错误行为保留草稿。
+one-shot 行始终会用文案替代输入框,说明执行记录为只读。可继续行仅在 `parentAvailable` 为 false 且 child 未在运行时如此;parent 离线但仍在运行的 child 保留普通输入框,并禁用其输入区和 Send 操作,让独立的 Stop 与在线 QueueDock 控制保持可达,停止后只读替代恢复。parent 在线时,即使 child 正在运行,普通 Enter/Cmd+Enter 偏好也会选择 Queue 或 best-effort Steer。对在线可继续 child,QueueDock Edit、Remove 与 Steer 在 parent 离线时仍可用;独立 Stop 经由 `subagent.interrupt` 路由([中断约定](2026-08-06-continuable-subagent-interrupt.zh.md))。提示词失败会通过普通错误行为保留草稿。
 
 
 已寻址 child 视图不提供绑定到 agent 的辅助控件。具体而言,模型选择器与 `/model` contribution 不会调用普通 `session.models` 或 `session.selectModel`;Host 也会拒绝任何意外调用,而不是在直接 parent 继续执行路径之外激活持久化 child 历史。
 已寻址 child 视图不提供绑定到 agent 的辅助控件。具体而言,模型选择器与 `/model` contribution 不会调用普通 `session.models` 或 `session.selectModel`;Host 也会拒绝任何意外调用,而不是在直接 parent 继续执行路径之外激活持久化 child 历史。
 
 
@@ -55,13 +55,13 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。
 
 
 - `subagent.list` 接受 `parentSessionId`,调用 `ctx.subagents.listChildren(parentSessionId, signal)`,返回完整有序的条目以及每个健康行的布尔 `hasChildren` 快照,把每个健康行的语料活动状态替换为其确切 Agent driver 是否正在运行,并说明当前能否从 `ctx.agents` 解析出确切 parent。
 - `subagent.list` 接受 `parentSessionId`,调用 `ctx.subagents.listChildren(parentSessionId, signal)`,返回完整有序的条目以及每个健康行的布尔 `hasChildren` 快照,把每个健康行的语料活动状态替换为其确切 Agent driver 是否正在运行,并说明当前能否从 `ctx.agents` 解析出确切 parent。
 - `subagent.history` 接受包含 mode 的完整地址与普通页参数。它对照直接目录校验 child 与 mode,通过 `ctx.sessionQuery.readSession()` 读取,再次检查直接谱系,并在不发布 agent 的情况下返回普通原始事件、渲染意图、分页与由 Host 计算的会话投影基线。
 - `subagent.history` 接受包含 mode 的完整地址与普通页参数。它对照直接目录校验 child 与 mode,通过 `ctx.sessionQuery.readSession()` 读取,再次检查直接谱系,并在不发布 agent 的情况下返回普通原始事件、渲染意图、分页与由 Host 计算的会话投影基线。
-- `subagent.prompt` 只接受 `mode: 'continuable'` 地址与上传形态的 `PromptContentPart[]`;Host 在投递前把图片部分准入并持久化为持久引用([图片投递](../../archived/bug-fix/2026-08-27-steer-followup-image-delivery.md))。它要求确切的存活 parent,重新校验目录地址,调用 `ctx.subagents.followup(parent, childId, content, { source, signal })`,并返回已接受的 `MessageId`。
+- `subagent.prompt` 只接受 `mode: 'continuable'` 地址、`delivery: 'queue' | 'steer'` 与上传形态的 `PromptContentPart[]`;Host 在投递前把图片部分准入并持久化为持久引用([图片投递](../../archived/bug-fix/2026-08-27-steer-followup-image-delivery.md))。它要求确切的存活 parent,重新校验目录地址,使用 continuation manager 共享的人类投递准入,并返回已接受的 `MessageId`。
 
 
 网关会将 parent 缺失、目录条目缺失或为 diagnostic、child 不可恢复或未授权、请求取消、图片准入或图片能力拒绝(`subagent/attachment-invalid`)以及继续执行准入暂时不可用等失败映射为类型化 RPC 错误。它不会公开描述符或提供方细节。list/prompt 竞态属于正常情况:权威依据是提示词操作的结果,而不是更早的可用性或活动快照。
 网关会将 parent 缺失、目录条目缺失或为 diagnostic、child 不可恢复或未授权、请求取消、图片准入或图片能力拒绝(`subagent/attachment-invalid`)以及继续执行准入暂时不可用等失败映射为类型化 RPC 错误。它不会公开描述符或提供方细节。list/prompt 竞态属于正常情况:权威依据是提示词操作的结果,而不是更早的可用性或活动快照。
 
 
 查看持久化历史本身不会创建 mux 订阅。当后续消息物化冷态 child Activation 时,现有 Host 与 mux 流会发布其生命周期与事件。重新连接时,系统通过 `subagent.history` 重建已寻址窗口。
 查看持久化历史本身不会创建 mux 订阅。当后续消息物化冷态 child Activation 时,现有 Host 与 mux 流会发布其生命周期与事件。重新连接时,系统通过 `subagent.history` 重建已寻址窗口。
 
 
-普通 `session.history` 路由对于普通会话和 subagent 会话同样只执行观察,但它既不携带目录地址,也不授予继续执行权限。每条需要 Agent 的普通路由都会在恢复冷会话前经过共享所有权栅栏;`session.cancel` 与 `session.updateQueue` 会直接执行同一检查,因为它们有意只查询已附加的 Agent
+普通 `session.history` 路由对于普通会话和 subagent 会话同样只执行观察,但它既不携带目录地址,也不授予继续执行权限。每条需要 Agent 的普通路由都会在恢复冷会话前经过共享所有权栅栏;`session.cancel` 保留该栅栏。`session.updateQueue` 只有一个目标本地例外:目标是在线 child,且其当前 projection identity 为 continuable 并来自自身的非 seed suffix;one-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 仍受栅栏阻挡
 
 
 适配器仍位于生成的 Remote 命名空间之后;`dsh-host-webserver` 仍作为载体。浏览器代码通过现有连接包导入约定,绝不直接访问宿主 `ctx`,从而保持[已归档的 GUI RPC 分层决策](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)。
 适配器仍位于生成的 Remote 命名空间之后;`dsh-host-webserver` 仍作为载体。浏览器代码通过现有连接包导入约定,绝不直接访问宿主 `ctx`,从而保持[已归档的 GUI RPC 分层决策](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)。
 
 

+ 2 - 2
.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md
-2026-07-28-continuable-subagent-conversations.md: fef7aba2080521253d7a67dd5169880fa5146dc0
-2026-07-28-continuable-subagent-conversations.zh.md: 875d6ab7ea5b8dd78276c3a7a9e789646340bc15
+2026-07-28-continuable-subagent-conversations.md: 8c3f2e1da593157f17528f13fc8842012aad0284
+2026-07-28-continuable-subagent-conversations.zh.md: 886e31fa3d88b78f875d51058bb2ec8c525837fe

+ 14 - 14
.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md

@@ -62,14 +62,14 @@ The internal residency lifecycle has three conditions and no separate `queued` s
 
 
 ```text
 ```text
 running
 running
-  | Agent quiescent with live children
+  | Agent quiescent with pending inbox or live children
   v
   v
 waiting
 waiting
-  | next-turn
+  | waking delivery
   +--------------------------> running
   +--------------------------> running
 
 
 running or waiting
 running or waiting
-  | Agent quiescent and no live children
+  | Agent quiescent, empty inbox, and no live children
   v
   v
 settled
 settled
   | AgentHandle.dispose completes
   | AgentHandle.dispose completes
@@ -77,15 +77,15 @@ settled
 no Activation
 no Activation
 ```
 ```
 
 
-`running` means the Agent has an active admission or turn, or its inbox contains waking work. `waiting` means the Agent is quiescent but the Activation still owns at least one child Activation that has not completed disposal. `settled` means the Agent is quiescent and every owned child is disposed; the manager then disposes the `AgentHandle` and removes the Activation.
+`running` means the Agent has an active admission or turn. `waiting` means the Agent is quiescent but its Inbox is nonempty or the Activation still owns at least one child Activation that has not completed disposal. `settled` means the Agent is quiescent, its Inbox is empty, and every owned child is disposed; the manager then disposes the `AgentHandle` and removes the Activation.
 
 
-The manager derives these states from Agent quiescence and the owned-child set rather than maintaining a second execution state machine. A `next-turn` delivered while `running` joins the Agent inbox. A `next-turn` delivered while `waiting` wakes the same Agent and returns the Activation to `running`. Delivery after disposal cold-resumes a new Activation.
+The manager derives these states from Agent quiescence, the Inbox's pending state, and the owned-child set rather than maintaining a second execution state machine. A `next-turn` delivered while `running` joins the Agent inbox. A waking delivery while `waiting` wakes the same Agent and returns the Activation to `running`. Delivery after disposal cold-resumes a new Activation.
 
 
-The manager linearizes delivery, child release, and disposal for each durable child. If a delivery races with final disposal, exactly one side wins the admission cutoff: delivery either enters the still-live Agent inbox, or waits for disposal and cold-resumes a new Activation. No caller can send to a handle after its disposal transaction begins.
+The manager linearizes manager-owned delivery, child release, and disposal for each durable child. A private `SubagentInbox` delegates Queue and Steer to the Agent inbox and owns the Activation's existing close transaction. If manager delivery races with final disposal, exactly one side wins this admission cutoff: delivery either enters the still-live Agent inbox, or observes closing and follows its operation-specific rejection or cold-resume path. Direct Agent work does not use this wrapper, so natural settlement uses short maintenance claims to validate the idle phase before the final flush and final disposal decision, then revalidates the Session sequence, Inbox pending state, wake generation, and owned-child set under the child lock. Accepted work that remains active or changes Session, Inbox, or ownership state invalidates that settlement attempt instead of being cancelled by it; maintenance that starts and finishes entirely during the flush has completed before the cutoff.
 
 
 ### One inbox and follow-up delivery
 ### One inbox and follow-up delivery
 
 
-The Agent inbox is the only queue. Every continuation message uses `Agent.followup()` and becomes one FIFO turn; neither the continuation manager nor the host maintains another message queue. Every accepted waking item keeps the current Activation live until `Agent.whenIdle()` observes the complete waking suffix.
+The Agent inbox is the only queue. Every continuation message uses `Agent.followup()` and becomes one FIFO turn; neither the continuation manager nor the host maintains another message queue. Every pending Inbox occurrence keeps the current Activation live until it is claimed or discarded. This conservative rule also retains injected context: a quiet injection that remains after quiescence can keep the Activation and its live ancestors resident until a waking delivery claims it, a queue mutation removes it, or manager teardown disposes the tree.
 
 
 Routing depends only on Activation residency:
 Routing depends only on Activation residency:
 
 
@@ -103,21 +103,21 @@ Every Activation owns its `AgentHandle` and an `ownedChildren: Set<SessionId>`.
 
 
 When the authenticated parent is itself a continuation-managed Activation, starting a child or submitting parent-originated work adds the child Session id to that parent's `ownedChildren` before the child can run or the message can enter its inbox. That parent cannot settle or dispose while this set is non-empty. A top-level or other non-continuation Agent has no Activation and does not join this waiting graph.
 When the authenticated parent is itself a continuation-managed Activation, starting a child or submitting parent-originated work adds the child Session id to that parent's `ownedChildren` before the child can run or the message can enter its inbox. That parent cannot settle or dispose while this set is non-empty. A top-level or other non-continuation Agent has no Activation and does not join this waiting graph.
 
 
-Child release occurs only after the child Agent is quiescent, every child of that child is disposed, the best-effort final session flush settles, and the child's `AgentHandle` completes disposal. The manager awaits `ctx.sessions.flush(child.session)` but does not interpret its participation boolean: an arbitrary listener cannot prove that the selected persistence backend stored the state. A rejection is logged without preventing handle disposal or ownership release, because retaining a child would permanently pin its ancestors in `waiting`. If the child is owned, the manager then resolves the live parent through `SessionHeader.parentSession` and removes the child Session id from its `ownedChildren`. Manager teardown uses the same child-first order.
+Child release occurs only after the child Agent is quiescent, its Inbox is empty, every child of that child is disposed, the best-effort final session flush settles, the same settlement facts survive a child-lock revalidation, and the child's `AgentHandle` completes disposal. The manager awaits `ctx.sessions.flush(child.session)` before closing admission but does not interpret its participation boolean: an arbitrary listener cannot prove that the selected persistence backend stored the state. A rejection is logged without preventing revalidation, handle disposal, or ownership release, because retaining a child would permanently pin its ancestors in `waiting`. If the child is owned, the manager then resolves the live parent through `SessionHeader.parentSession` and removes the child Session id from its `ownedChildren`. Manager teardown uses the same child-first order but closes admission and stops work immediately rather than performing natural-settlement revalidation.
 
 
 Ownership is retained until the child Activation is disposed. A later refinement may release a request-scoped lease earlier, but it would require an exact turn-completion correlation that this Task-free design deliberately does not add.
 Ownership is retained until the child Activation is disposed. A later refinement may release a request-scoped lease earlier, but it would require an exact turn-completion correlation that this Task-free design deliberately does not add.
 
 
 Top-level teardown is host-owned rather than represented as another Activation. Manager unload invokes its internal manager-wide drain to close admission synchronously, await every admitted materialization through publication or rollback, stop the stable live forest, and release it child-first. A host that owns selected top-level Agents uses `drainContinuableDescendants(parents)`: exact Agent identities close admission only below those roots until each leaves the registry, while unrelated forests and manager-wide admission remain live; the manager stops their visible descendants before its first await, waits only materializations admitted below those roots, and releases only the selected branches. Every materialized start and live delivery rechecks caller cancellation, the applicable draining scope, Activation disposal, and exact parent authority in the same synchronous span as inbox submission, so teardown or parent replacement that wins before acceptance prevents delivery to the closing handle. Only after the applicable drain settles may the host dispose its top-level Agents; only manager-wide drain precedes manager-scope disposal.
 Top-level teardown is host-owned rather than represented as another Activation. Manager unload invokes its internal manager-wide drain to close admission synchronously, await every admitted materialization through publication or rollback, stop the stable live forest, and release it child-first. A host that owns selected top-level Agents uses `drainContinuableDescendants(parents)`: exact Agent identities close admission only below those roots until each leaves the registry, while unrelated forests and manager-wide admission remain live; the manager stops their visible descendants before its first await, waits only materializations admitted below those roots, and releases only the selected branches. Every materialized start and live delivery rechecks caller cancellation, the applicable draining scope, Activation disposal, and exact parent authority in the same synchronous span as inbox submission, so teardown or parent replacement that wins before acceptance prevents delivery to the closing handle. Only after the applicable drain settles may the host dispose its top-level Agents; only manager-wide drain precedes manager-scope disposal.
 
 
-The activation-owner scope exists because ordinary Cordis owner effects unwind in reverse registration order, which cannot express the dynamic child graph. Manager initialization registers the private scope's structural disposer first and its drain disposer afterward, so reverse unwind invokes the drain before releasing that scope; merely registering a cleanup effect on the same scope as later Agent handles would allow structural handle disposal to bypass child-first ordering. Each materialization registers its barrier participant and snapshots its exact live ancestry before starting the inner transaction, then remains tracked until it installs an Activation or fully rolls back. The Activation retains weak membership of that ancestry, so an intermediate Agent may leave the registry without hiding a still-live descendant from its host root. Each Activation installs one memoized disposal promise before cancellation or recursive callbacks, allowing scoped host shutdown, global manager unload, child release, and normal settlement to converge without double release. Cancellation propagates top-down before slow descendant cleanup; handle release remains child-first. Sibling branches drain independently; one disposal failure is recorded but does not prevent the manager from attempting the remaining selected handles, and the aggregate drain reports failure after all selected branches settle. Durable child Sessions survive this process-local teardown.
+The activation-owner scope exists because ordinary Cordis owner effects unwind in reverse registration order, which cannot express the dynamic child graph. Manager initialization registers the private scope's structural disposer first and its drain disposer afterward, so reverse unwind invokes the drain before releasing that scope; merely registering a cleanup effect on the same scope as later Agent handles would allow structural handle disposal to bypass child-first ordering. Each materialization registers its barrier participant and snapshots its exact live ancestry before starting the inner transaction, then remains tracked until it installs an Activation or fully rolls back. The Activation retains weak membership of that ancestry, so an intermediate Agent may leave the registry without hiding a still-live descendant from its host root. Its private `SubagentInbox` installs one memoized closing promise before cancellation or recursive callbacks, allowing scoped host shutdown, global manager unload, child release, and normal settlement to converge without double release. Cancellation propagates top-down before slow descendant cleanup; handle release remains child-first. Sibling branches drain independently; one disposal failure is recorded but does not prevent the manager from attempting the remaining selected handles, and the aggregate drain reports failure after all selected branches settle. Durable child Sessions survive this process-local teardown.
 
 
 ### Adjacent-Agent messaging
 ### Adjacent-Agent messaging
 
 
 The shared `sendMessage(sender, targetId, content, options)` service operation adds no second queue. It accepts an exact live sender, permits only its direct parent or direct continuable child, and uses fixed Steer scheduling through the Agent inbox. The global `send_message({ agent_id, message })` tool exposes that same operation in both directions; the child's initial task identifies its direct parent when the tool is visible. The [adjacent-Agent messaging Agent Note](../architecture/2026-08-27-adjacent-agent-steer-messaging.md) owns its schema, authority, attribution, and prompt placement.
 The shared `sendMessage(sender, targetId, content, options)` service operation adds no second queue. It accepts an exact live sender, permits only its direct parent or direct continuable child, and uses fixed Steer scheduling through the Agent inbox. The global `send_message({ agent_id, message })` tool exposes that same operation in both directions; the child's initial task identifies its direct parent when the tool is visible. The [adjacent-Agent messaging Agent Note](../architecture/2026-08-27-adjacent-agent-steer-messaging.md) owns its schema, authority, attribution, and prompt placement.
 
 
-### Fixed Steer scheduling
+### Agent and human scheduling
 
 
-Every accepted Agent message uses `Agent.steer()`. A running target claims it at the nearest step boundary; an idle or cold-resumed target starts a turn. The continuation layer does not expose a caller-selectable quiet, next-turn, or follow-up mode.
+Every accepted Agent message uses `Agent.steer()`. A running target claims it at the nearest step boundary; an idle or cold-resumed target starts a turn. Browser-authored human input separately carries `delivery: 'queue' | 'steer'` through `subagent.prompt`: Queue opens a later FIFO turn, while Steer uses the same best-effort nearest-step scheduling without changing the message's human provenance. The public service exposes no caller-selectable scheduling mode for Agent messages.
 
 
 ### Authority and recorded sender identity
 ### Authority and recorded sender identity
 
 
@@ -133,7 +133,7 @@ Without Jobs there is no `job_output`, `job_kill`, Task status, or per-message r
 
 
 Host and manager teardown remains the lifecycle stop path. Manager unload applies it globally; a host applies it only below the exact top-level Agents it owns. Each form closes the applicable admission scope, stops the selected visible Activations, awaits admitted materializations in that scope, releases child-first, and preserves the durable Sessions.
 Host and manager teardown remains the lifecycle stop path. Manager unload applies it globally; a host applies it only below the exact top-level Agents it owns. Each form closes the applicable admission scope, stops the selected visible Activations, awaits admitted materializations in that scope, releases child-first, and preserves the durable Sessions.
 
 
-Each turn requests the Session durability checkpoint, while final Activation settlement additionally awaits `ctx.sessions.flush()` as a best-effort barrier. The manager deliberately ignores the boolean result because listener participation cannot identify a persistence backend. A rejection is logged without changing the lifecycle result or host-drain outcome; the manager still disposes the handle and releases ownership, and the persisted child state may be missing or stale on a later resume.
+Each turn requests the Session durability checkpoint, while final Activation settlement additionally awaits `ctx.sessions.flush()` as a best-effort barrier before closing admission. The manager then revalidates that no Agent, Inbox, Session, or owned-child state changed during the await; a changed observation retries settlement and flushes the newer state. The manager deliberately ignores the flush boolean because listener participation cannot identify a persistence backend. A rejection is logged without changing the lifecycle result or host-drain outcome; the manager still performs the final revalidation, disposes the handle when it succeeds, and releases ownership, while the persisted child state may be missing or stale on a later resume.
 
 
 Only messages written to the child Session log are reconstructable with the source that supplied them; inbox acceptance alone provides no restart guarantee.
 Only messages written to the child Session log are reconstructable with the source that supplied them; inbox acceptance alone provides no restart guarantee.
 
 
@@ -189,13 +189,13 @@ The implementation pins these behaviors:
 - An idle Agent with live owned children yields a `waiting` Activation whose `AgentHandle` remains retained.
 - An idle Agent with live owned children yields a `waiting` Activation whose `AgentHandle` remains retained.
 - A `next-turn` delivered to `waiting` wakes the same Activation; delivery after completed disposal cold-resumes a new Activation.
 - A `next-turn` delivered to `waiting` wakes the same Activation; delivery after completed disposal cold-resumes a new Activation.
 - Every continuation-managed parent Activation disposes only after all directly owned child Activations complete `AgentHandle` disposal; top-level Agents do not join the waiting graph.
 - Every continuation-managed parent Activation disposes only after all directly owned child Activations complete `AgentHandle` disposal; top-level Agents do not join the waiting graph.
-- Final Activation settlement awaits `ctx.sessions.flush(child.session)` as a best-effort barrier, logs rejection without interpreting listener participation as durability proof, then disposes the child handle and releases parent ownership so a flush failure cannot leak a `waiting` Activation.
+- Final Activation settlement awaits `ctx.sessions.flush(child.session)` with admission open, logs rejection without interpreting listener participation as durability proof, revalidates the final state under the child lock, then closes admission, disposes the child handle, and releases parent ownership so a flush failure cannot leak a `waiting` Activation.
 - Manager teardown closes admission globally; a host owning selected top-level Agents instead closes admission only below their exact identities until those roots leave the registry. Both track admitted materializations by exact ancestry, install one memoized disposal cutoff per selected visible Activation, propagate cancellation top-down, release handles child-first, await every selected branch despite individual failures, and only then dispose the corresponding top-level Agents or manager scope.
 - Manager teardown closes admission globally; a host owning selected top-level Agents instead closes admission only below their exact identities until those roots leave the registry. Both track admitted materializations by exact ancestry, install one memoized disposal cutoff per selected visible Activation, propagate cancellation top-down, release handles child-first, await every selected branch despite individual failures, and only then dispose the corresponding top-level Agents or manager scope.
 - The base lifecycle has no implicit report behavior; the optional report package contributes an explicit child-scoped tool through the setup hook.
 - The base lifecycle has no implicit report behavior; the optional report package contributes an explicit child-scoped tool through the setup hook.
 - Session logs reconstruct only messages that were actually written, with the source that supplied each message; inbox-accepted but unlogged messages have no restart guarantee.
 - Session logs reconstruct only messages that were actually written, with the source that supplied each message; inbox-accepted but unlogged messages have no restart guarantee.
 - No continuable-subagent path creates or depends on a Task, `JobId`, Task completion notice, Task cancellation, or intermediate result-bearing execution wrapper.
 - No continuable-subagent path creates or depends on a Task, `JobId`, Task completion notice, Task cancellation, or intermediate result-bearing execution wrapper.
 - Unit coverage pins the `startContinuable()` inbox-acceptance return boundary, complete rollback for each pre-acceptance and lifecycle-publication failure, global and parent-scoped drain quiescence for materialization caught between Agent publication and Activation registration, sibling-forest isolation, exact ancestry after an intermediate Agent leaves the registry, provider-independent cold resume, final exact-parent reauthorization after cold-resume materialization, caller-signal and teardown ownership on both sides of acceptance, and the absence of automatic replay for accepted-but-unlogged messages.
 - Unit coverage pins the `startContinuable()` inbox-acceptance return boundary, complete rollback for each pre-acceptance and lifecycle-publication failure, global and parent-scoped drain quiescence for materialization caught between Agent publication and Activation registration, sibling-forest isolation, exact ancestry after an intermediate Agent leaves the registry, provider-independent cold resume, final exact-parent reauthorization after cold-resume materialization, caller-signal and teardown ownership on both sides of acceptance, and the absence of automatic replay for accepted-but-unlogged messages.
-- Unit coverage pins the residency-only routing table, single-inbox ordering, `MessageId` correlation through inbox events, follow-up during an open turn, waiting wakeup, cold resume, ownership registration and release, child-first disposal, send-versus-dispose races, best-effort final flush with absent and failing listeners, and the absence of public subagent cancellation and steering.
+- Unit coverage pins the residency-only routing table, single-inbox ordering, `MessageId` correlation through inbox events, follow-up during an open turn, waiting wakeup, cold resume, ownership registration and release, child-first disposal, send-versus-dispose races, direct Agent turns, Session-only work, and maintenance accepted during the final-flush await, best-effort final flush with absent and failing listeners, and the absence of public subagent cancellation and steering.
 - Report-package unit coverage separately pins child-only visibility, setup revocation, authority, delivery modes, stable message identity, and lifecycle races.
 - Report-package unit coverage separately pins child-only visibility, setup revocation, authority, delivery modes, stable message identity, and lifecycle races.
 - A keyless assembled-app snapshot covers parent delegation and follow-up queueing, the absence of subagent steering and implicit report delivery, retained waiting `AgentHandle`, and child-first disposal. A separate report snapshot covers the optional explicit return channel.
 - A keyless assembled-app snapshot covers parent delegation and follow-up queueing, the absence of subagent steering and implicit report delivery, retained waiting `AgentHandle`, and child-first disposal. A separate report snapshot covers the optional explicit return channel.
 
 

+ 14 - 14
.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md

@@ -62,14 +62,14 @@ inbox 接受消息前发生任何失败,操作都会在不返回任何 id 的
 
 
 ```text
 ```text
 running
 running
-  | Agent quiescent with live children
+  | Agent quiescent with pending inbox or live children
   v
   v
 waiting
 waiting
-  | next-turn
+  | waking delivery
   +--------------------------> running
   +--------------------------> running
 
 
 running or waiting
 running or waiting
-  | Agent quiescent and no live children
+  | Agent quiescent, empty inbox, and no live children
   v
   v
 settled
 settled
   | AgentHandle.dispose completes
   | AgentHandle.dispose completes
@@ -77,15 +77,15 @@ settled
 no Activation
 no Activation
 ```
 ```
 
 
-`running` 表示 Agent 正在执行准入或轮次,或者 inbox 中存在会唤醒 Agent 的工作。`waiting` 表示 Agent 已经完全停稳,但激活仍持有至少一个尚未完成 dispose 的 child 激活。`settled` 表示 Agent 已经完全停稳且所有持有的 child 都已 dispose;随后管理器会 dispose `AgentHandle` 并移除激活。
+`running` 表示 Agent 正在执行准入或轮次。`waiting` 表示 Agent 已经完全停稳,但其 Inbox 非空,或激活仍持有至少一个尚未完成 dispose 的 child 激活。`settled` 表示 Agent 已经完全停稳、其 Inbox 为空且所有持有的 child 都已 dispose;随后管理器会 dispose `AgentHandle` 并移除激活。
 
 
-管理器根据 Agent 是否完全停稳以及所持 child 集合派生这些状态,而不是维护第二套执行状态机。在 `running` 时投递的 `next-turn` 会进入 Agent inbox。在 `waiting` 时投递的 `next-turn` 会唤醒同一个 Agent,并使激活回到 `running`。在 dispose 完成后投递消息则会冷恢复新激活。
+管理器根据 Agent 是否完全停稳、Inbox 的待处理状态以及所持 child 集合派生这些状态,而不是维护第二套执行状态机。在 `running` 时投递的 `next-turn` 会进入 Agent inbox。在 `waiting` 时到达的唤醒投递会唤醒同一个 Agent,并使激活回到 `running`。在 dispose 完成后投递消息则会冷恢复新激活。
 
 
-管理器会针对每个持久化 child,将投递、child 释放和 dispose 线性化。如果投递与最终 dispose 发生竞争,只有一方能越过准入截止点:投递要么进入仍在线的 Agent inbox,要么等待 dispose 完成后冷恢复新激活。任何调用方都不能向已经开始 dispose 事务的 handle 发送消息
+管理器会针对每个持久化 child,将 manager 所有的投递、child 释放和 dispose 线性化。私有 `SubagentInbox` 会把 Queue 与 Steer 委托给 Agent inbox,并持有 Activation 既有的关闭事务。如果 manager 投递与最终 dispose 发生竞争,只有一方能越过这条准入截止点:投递要么进入仍在线的 Agent inbox,要么观察到正在关闭,并遵循该操作特有的拒绝或冷恢复路径。直接操作 Agent 的工作不经过这层包装,因此自然结算会通过短暂的 maintenance 占用,在最终 flush 与最终 dispose 决策之前验证 idle 阶段,并在 child lock 内重新验证 Session 序号、Inbox 待处理状态、wake generation 与 owned-child set。仍然活跃或改变 Session、Inbox 或所有权状态的已接受工作会让本次结算尝试失效,而不会被它取消;完全在 flush 期间开始并结束的 maintenance 已在截止点前完成
 
 
 ### 一个 inbox 与 follow-up 投递
 ### 一个 inbox 与 follow-up 投递
 
 
-Agent inbox 是唯一队列。每条继续执行消息都使用 `Agent.followup()`,并成为一个 FIFO 轮次;继续执行管理器和宿主都不维护另一条消息队列。每个已接受且会唤醒 Agent 的条目都会让当前激活保持在线,直至 `Agent.whenIdle()` 观察到完整的唤醒工作后缀已经结束
+Agent inbox 是唯一队列。每条继续执行消息都使用 `Agent.followup()`,并成为一个 FIFO 轮次;继续执行管理器和宿主都不维护另一条消息队列。每个待处理 Inbox occurrence 都会让当前激活保持在线,直到它被 claim 或 discard。这条保守规则也会保留注入 context:完全停稳后仍存在的静默注入可以让 Activation 及其在线祖先继续驻留,直到唤醒投递将其 claim、queue 变更将其移除,或 manager teardown dispose 整棵树
 
 
 路由只取决于激活的驻留状态:
 路由只取决于激活的驻留状态:
 
 
@@ -103,21 +103,21 @@ Agent inbox 是唯一队列。每条继续执行消息都使用 `Agent.followup(
 
 
 当经过身份认证的 parent 自身是由继续执行管理器管理的激活时,启动 child 或提交由 parent 发起的工作,会在 child 可以运行或消息可以进入其 inbox 前,将 child 会话 id 加入该 parent 的 `ownedChildren`。该集合非空时,这个 parent 不能结算或 dispose。顶层 Agent 或其他非继续执行 Agent 没有激活,也不会加入该等待图。
 当经过身份认证的 parent 自身是由继续执行管理器管理的激活时,启动 child 或提交由 parent 发起的工作,会在 child 可以运行或消息可以进入其 inbox 前,将 child 会话 id 加入该 parent 的 `ownedChildren`。该集合非空时,这个 parent 不能结算或 dispose。顶层 Agent 或其他非继续执行 Agent 没有激活,也不会加入该等待图。
 
 
-只有在 child Agent 完全停稳、该 child 持有的每个 child 都已 dispose、best-effort 的最终会话 flush 结算且 child 的 `AgentHandle` 完成 dispose 后,系统才释放 child。管理器会等待 `ctx.sessions.flush(child.session)`,但不解释其参与布尔值:任意 listener 都无法证明所选持久化后端已存储该状态。rejection 会被记录,但不会阻止 handle dispose 或释放所有权,因为保留 child 会让其祖先永久固定在 `waiting`。如果 child 归 parent 所有,管理器随后会通过 `SessionHeader.parentSession` 解析在线 parent,并从其 `ownedChildren` 中移除 child 会话 id。管理器拆卸使用相同的 child-first 顺序
+只有在 child Agent 完全停稳、其 Inbox 为空、该 child 持有的每个 child 都已 dispose、best-effort 的最终会话 flush 结算、相同结算事实通过 child-lock 重验且 child 的 `AgentHandle` 完成 dispose 后,系统才释放 child。管理器会在关闭准入前等待 `ctx.sessions.flush(child.session)`,但不解释其参与布尔值:任意 listener 都无法证明所选持久化后端已存储该状态。系统会记录 rejection,但不会让它阻止重验、handle dispose 或释放所有权,因为保留 child 会让其祖先永久固定在 `waiting`。如果 child 归 parent 所有,管理器随后会通过 `SessionHeader.parentSession` 解析在线 parent,并从其 `ownedChildren` 中移除 child 会话 id。Manager teardown 使用相同的 child-first 顺序,但会立即关闭准入并停止工作,而不执行自然结算重验
 
 
 系统会一直保留所有权,直至 child 激活完成 dispose。后续改进可以更早释放限定到请求的 lease,但这需要精确关联轮次完成,而本 Task-free 设计特意不增加该机制。
 系统会一直保留所有权,直至 child 激活完成 dispose。后续改进可以更早释放限定到请求的 lease,但这需要精确关联轮次完成,而本 Task-free 设计特意不增加该机制。
 
 
 顶层拆卸由宿主负责,而不表示为另一次激活。管理器卸载会调用其内部的管理器全局 drain,同步关闭准入,等待每个已获准的物化过程完成发布或回滚,停止稳定的在线森林,并按 child-first 顺序释放。拥有选定顶层 Agent 的宿主使用 `drainContinuableDescendants(parents)`:确切的 Agent 身份只关闭这些根之下的准入,直到每个身份离开注册表,而无关森林和管理器全局准入保持在线;管理器会在第一次 await 之前停止其可见后代,只等待这些根之下已获准的物化过程,并且只释放选定分支。每个已物化的 start 和在线投递都会在与 inbox 提交相同的同步区间内重新检查调用方取消、适用的 draining 作用域、Activation dispose 和确切的 parent 权限,因此只要拆卸或 parent 替换先于接受发生,就会阻止向正在关闭的 handle 投递。只有适用的 drain 结算后,宿主才能 dispose 自己的顶层 Agent;只有管理器全局 drain 会先于管理器作用域 dispose。
 顶层拆卸由宿主负责,而不表示为另一次激活。管理器卸载会调用其内部的管理器全局 drain,同步关闭准入,等待每个已获准的物化过程完成发布或回滚,停止稳定的在线森林,并按 child-first 顺序释放。拥有选定顶层 Agent 的宿主使用 `drainContinuableDescendants(parents)`:确切的 Agent 身份只关闭这些根之下的准入,直到每个身份离开注册表,而无关森林和管理器全局准入保持在线;管理器会在第一次 await 之前停止其可见后代,只等待这些根之下已获准的物化过程,并且只释放选定分支。每个已物化的 start 和在线投递都会在与 inbox 提交相同的同步区间内重新检查调用方取消、适用的 draining 作用域、Activation dispose 和确切的 parent 权限,因此只要拆卸或 parent 替换先于接受发生,就会阻止向正在关闭的 handle 投递。只有适用的 drain 结算后,宿主才能 dispose 自己的顶层 Agent;只有管理器全局 drain 会先于管理器作用域 dispose。
 
 
-activation-owner 作用域之所以存在,是因为普通 Cordis owner effect 按注册逆序撤销,无法表达动态 child 图。管理器初始化时先注册私有作用域的结构化 disposer,再注册自身的 drain disposer,使逆序撤销先执行 drain、再释放该作用域;如果只在与后续 Agent handle 相同的作用域上注册 cleanup effect,结构化 handle dispose 就可能绕过 child-first 顺序。每个物化过程都会在启动内部事务前注册其屏障参与项,并对其确切的在线祖先建立快照,然后保持跟踪,直到安装 Activation 或完全回滚。Activation 会保留其在这组祖先中的弱成员关系,因此中间 Agent 即使离开注册表,也不会让仍在线的后代脱离宿主根节点的可见范围。每个 Activation 都会在取消或递归回调前安装一个记忆化的 dispose promise,使限定作用域的宿主关闭、全局管理器卸载、child 释放和正常结算能够汇合,而不会重复释放。取消会在等待缓慢的后代清理之前自顶向下传播;handle 释放仍是 child-first。同级分支独立 drain;系统会记录单次 dispose 失败,但仍会尝试其余选中 handle,聚合 drain 则在所有选中分支结算后报告失败。这次进程内拆卸不会销毁持久化 child 会话。
+activation-owner 作用域之所以存在,是因为普通 Cordis owner effect 按注册逆序撤销,无法表达动态 child 图。管理器初始化时先注册私有作用域的结构化 disposer,再注册自身的 drain disposer,使逆序撤销先执行 drain、再释放该作用域;如果只在与后续 Agent handle 相同的作用域上注册 cleanup effect,结构化 handle dispose 就可能绕过 child-first 顺序。每个物化过程都会在启动内部事务前注册其屏障参与项,并对其确切的在线祖先建立快照,然后保持跟踪,直到安装 Activation 或完全回滚。Activation 会保留其在这组祖先中的弱成员关系,因此中间 Agent 即使离开注册表,也不会让仍在线的后代脱离宿主根节点的可见范围。其私有 `SubagentInbox` 会在取消或递归回调前安装一个记忆化的 closing promise,使限定作用域的宿主关闭、全局管理器卸载、child 释放和正常结算能够汇合,而不会重复释放。取消会在等待缓慢的后代清理之前自顶向下传播;handle 释放仍是 child-first。同级分支独立 drain;系统会记录单次 dispose 失败,但仍会尝试其余选中 handle,聚合 drain 则在所有选中分支结算后报告失败。这次进程内拆卸不会销毁持久化 child 会话。
 
 
 ### 相邻 Agent 消息
 ### 相邻 Agent 消息
 
 
 共享的 `sendMessage(sender, targetId, content, options)` 服务操作不会增加第二条队列。它接收确切在线 sender,只允许其直接 parent 或直接可继续 child,并通过 Agent inbox 使用固定 Steer 调度。全局 `send_message({ agent_id, message })` 工具在两个方向暴露同一个操作;当 child 可以看到该工具时,其初始任务会标明直接 parent。[相邻 Agent 消息 Agent Note](../architecture/2026-08-27-adjacent-agent-steer-messaging.zh.md)规定其 schema、权限、来源信息与提示词位置。
 共享的 `sendMessage(sender, targetId, content, options)` 服务操作不会增加第二条队列。它接收确切在线 sender,只允许其直接 parent 或直接可继续 child,并通过 Agent inbox 使用固定 Steer 调度。全局 `send_message({ agent_id, message })` 工具在两个方向暴露同一个操作;当 child 可以看到该工具时,其初始任务会标明直接 parent。[相邻 Agent 消息 Agent Note](../architecture/2026-08-27-adjacent-agent-steer-messaging.zh.md)规定其 schema、权限、来源信息与提示词位置。
 
 
-### 固定 Steer 调度
+### Agent 与人类调度
 
 
-每条已接受的 Agent 消息都使用 `Agent.steer()`。运行中的目标会在最近的 step 边界领取消息;空闲或冷恢复的目标会启动一个轮次。继续执行层不暴露由调用方选择的 quiet、next-turn 或 follow-up 模式。
+每条已接受的 Agent 消息都使用 `Agent.steer()`。运行中的目标会在最近的 step 边界领取消息;空闲或冷恢复的目标会启动一个轮次。浏览器编写的人类输入会另行通过 `subagent.prompt` 携带 `delivery: 'queue' | 'steer'`:Queue 开启后续 FIFO 轮次,Steer 使用相同的 best-effort 最近 step 调度,并保留消息的人类来源。公开服务不为 Agent 消息提供调用方可选的调度模式。
 
 
 ### 权限与已记录的发送方身份
 ### 权限与已记录的发送方身份
 
 
@@ -133,7 +133,7 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect
 
 
 宿主和管理器拆卸仍是生命周期停止路径。管理器卸载会全局应用它;宿主只会在自己确切拥有的顶层 Agent 之下应用它。两种形式都会关闭适用的准入作用域,停止选中的可见 Activation,等待该作用域中已获准的物化过程,按 child-first 顺序释放,并保留持久化 Session。
 宿主和管理器拆卸仍是生命周期停止路径。管理器卸载会全局应用它;宿主只会在自己确切拥有的顶层 Agent 之下应用它。两种形式都会关闭适用的准入作用域,停止选中的可见 Activation,等待该作用域中已获准的物化过程,按 child-first 顺序释放,并保留持久化 Session。
 
 
-每个轮次都会请求执行会话持久性检查点,而 Activation 最终结算还会等待 `ctx.sessions.flush()`,将其作为 best-effort 屏障。管理器特意忽略布尔结果,因为 listener 是否参与无法标识持久化后端。rejection 会被记录,但不会改变生命周期结果或宿主 drain 的结果;管理器仍会 dispose handle 并释放所有权,后续恢复时持久化 child 状态可能缺失或陈旧。
+每个轮次都会请求执行会话持久性检查点,而 Activation 最终结算还会在关闭准入前等待 `ctx.sessions.flush()`,将其作为 best-effort 屏障。管理器随后会重新验证 await 期间没有 Agent、Inbox、Session 或 owned-child 状态发生变化;观察发生变化时,系统会重试结算并 flush 更新后的状态。管理器特意忽略 flush 布尔结果,因为 listener 是否参与无法标识持久化后端。系统会记录 rejection,但不会改变生命周期结果或宿主 drain 的结果;管理器仍会执行最终重验,在重验成功时 dispose handle 并释放所有权,后续恢复时持久化 child 状态可能缺失或陈旧。
 
 
 只有实际写入 child 会话日志的消息,才能在重建时保留提供它的来源;仅被 inbox 接受并不提供重启保证。
 只有实际写入 child 会话日志的消息,才能在重建时保留提供它的来源;仅被 inbox 接受并不提供重启保证。
 
 
@@ -189,13 +189,13 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect
 - 带有在线所持 child 的空闲 Agent 会产生 `waiting` 激活,其 `AgentHandle` 继续保留。
 - 带有在线所持 child 的空闲 Agent 会产生 `waiting` 激活,其 `AgentHandle` 继续保留。
 - 向 `waiting` 投递 `next-turn` 会唤醒同一个激活;完成 dispose 后投递消息会冷恢复新激活。
 - 向 `waiting` 投递 `next-turn` 会唤醒同一个激活;完成 dispose 后投递消息会冷恢复新激活。
 - 每个由继续执行管理器管理的 parent 激活只会在直接持有的所有 child 激活完成 `AgentHandle` dispose 后进行 dispose;顶层 Agent 不加入等待图。
 - 每个由继续执行管理器管理的 parent 激活只会在直接持有的所有 child 激活完成 `AgentHandle` dispose 后进行 dispose;顶层 Agent 不加入等待图。
-- Activation 最终结算会等待 `ctx.sessions.flush(child.session)`,将其作为 best-effort 屏障;它会记录 rejection,但不会把 listener 参与解释为持久性证明,然后 dispose child handle 并释放 parent 所有权,使 flush 失败不会泄漏 `waiting` Activation。
+- Activation 最终结算会在准入开放时等待 `ctx.sessions.flush(child.session)`,将其作为 best-effort 屏障;它会记录 rejection,但不会把 listener 参与解释为持久性证明,随后在 child lock 内重新验证最终状态,再关闭准入、dispose child handle 并释放 parent 所有权,使 flush 失败不会泄漏 `waiting` Activation。
 - 管理器拆卸会全局关闭准入;拥有选定顶层 Agent 的宿主则只关闭这些确切身份之下的准入,直到这些根离开注册表。两者都会按确切祖先关系跟踪已获准的物化过程,为每个选中的可见 Activation 安装一个记忆化 dispose 截止点,自顶向下传播取消,按 child-first 顺序释放 handle,即使个别分支失败也会等待所有选中分支,之后才 dispose 对应的顶层 Agent 或管理器作用域。
 - 管理器拆卸会全局关闭准入;拥有选定顶层 Agent 的宿主则只关闭这些确切身份之下的准入,直到这些根离开注册表。两者都会按确切祖先关系跟踪已获准的物化过程,为每个选中的可见 Activation 安装一个记忆化 dispose 截止点,自顶向下传播取消,按 child-first 顺序释放 handle,即使个别分支失败也会等待所有选中分支,之后才 dispose 对应的顶层 Agent 或管理器作用域。
 - 基础生命周期不暴露隐式报告行为;可选的 report 包通过 setup 钩子贡献一个显式的 child 作用域工具。
 - 基础生命周期不暴露隐式报告行为;可选的 report 包通过 setup 钩子贡献一个显式的 child 作用域工具。
 - 会话日志只会重建实际写入的消息,并保留每条消息的提供来源;已被 inbox 接受但未写入日志的消息没有重启保证。
 - 会话日志只会重建实际写入的消息,并保留每条消息的提供来源;已被 inbox 接受但未写入日志的消息没有重启保证。
 - 可继续 subagent 路径不创建或依赖 Task、`JobId`、Task 完成通知、Task 取消或中间的带结果执行包装层。
 - 可继续 subagent 路径不创建或依赖 Task、`JobId`、Task 完成通知、Task 取消或中间的带结果执行包装层。
 - 单元覆盖固定 `startContinuable()` 在 inbox 接受消息时的返回边界、每条接受前和生命周期发布失败路径的完整回滚、全局和限定到 parent 作用域的 drain 都会等待夹在 Agent 发布与 Activation 注册之间的物化过程完全停稳、同级森林隔离、中间 Agent 离开注册表后的确切祖先关系、不依赖提供方的冷恢复、冷恢复物化后的最终确切 parent 再授权、接受前后两个阶段的调用方 signal 与拆卸所有权,以及已接受但未写入日志的消息不会自动回放。
 - 单元覆盖固定 `startContinuable()` 在 inbox 接受消息时的返回边界、每条接受前和生命周期发布失败路径的完整回滚、全局和限定到 parent 作用域的 drain 都会等待夹在 Agent 发布与 Activation 注册之间的物化过程完全停稳、同级森林隔离、中间 Agent 离开注册表后的确切祖先关系、不依赖提供方的冷恢复、冷恢复物化后的最终确切 parent 再授权、接受前后两个阶段的调用方 signal 与拆卸所有权,以及已接受但未写入日志的消息不会自动回放。
-- 单元覆盖固定仅由驻留状态决定的路由表、单 inbox 顺序、通过 inbox 事件关联 `MessageId`、在开放轮次期间 follow-up、等待唤醒、冷恢复、所有权注册与释放、child-first dispose、发送与 dispose 的竞争、没有 listener 和 listener 失败时的 best-effort 最终 flush,以及不存在公开 subagent 取消和 steering。
+- 单元覆盖固定仅由驻留状态决定的路由表、单 inbox 顺序、通过 inbox 事件关联 `MessageId`、在开放轮次期间 follow-up、等待唤醒、冷恢复、所有权注册与释放、child-first dispose、发送与 dispose 的竞争、在最终 flush await 期间接受的直接 Agent 轮次、仅修改 Session 的工作与 maintenance、没有 listener 和 listener 失败时的 best-effort 最终 flush,以及不存在公开 subagent 取消和 steering。
 - report 包的单元覆盖会分别固定仅 child 可见性、setup 撤销、权限、投递模式、稳定消息身份和生命周期竞争。
 - report 包的单元覆盖会分别固定仅 child 可见性、setup 撤销、权限、投递模式、稳定消息身份和生命周期竞争。
 - 一项无密钥整套应用快照覆盖 parent 委派和 follow-up 排队、不存在 subagent steering 和隐式 report 投递、保留 waiting 中的 `AgentHandle` 以及 child-first dispose。另一项 report 快照覆盖可选的显式返回通道。
 - 一项无密钥整套应用快照覆盖 parent 委派和 follow-up 排队、不存在 subagent steering 和隐式 report 投递、保留 waiting 中的 `AgentHandle` 以及 child-first dispose。另一项 report 快照覆盖可选的显式返回通道。
 
 

+ 2 - 2
.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.md
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.md
-2026-08-06-manager-owned-subagent-settlement-delivery.md: d06245eacd3b7453a031716b1921015a5e38a25c
-2026-08-06-manager-owned-subagent-settlement-delivery.zh.md: e5259e97f185203ed77ae9427e5523ac29d4162f
+2026-08-06-manager-owned-subagent-settlement-delivery.md: f223571dc91300d085b7fcf0a9e3196daa48b760
+2026-08-06-manager-owned-subagent-settlement-delivery.zh.md: 4bbee373aa2b50902af5319f9398a89da9cc3143

+ 2 - 2
.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.md

@@ -26,9 +26,9 @@ The notice carries `{ kind: 'subagent-settled', form: 'notice', summary, senderS
 
 
 An external `ctx.on('subagent/end')` listener looks more decoupled and is wrong. `SubagentRunEndInfo` names no parent, the child handle is already disposed when the edge fires so the parent cannot be recovered from it, and the ownership release that wakes the parent's own settlement watcher has already run. The manager holds the parent reference throughout disposal, so none of those obstacles exist for it.
 An external `ctx.on('subagent/end')` listener looks more decoupled and is wrong. `SubagentRunEndInfo` names no parent, the child handle is already disposed when the edge fires so the parent cannot be recovered from it, and the ownership release that wakes the parent's own settlement watcher has already run. The manager holds the parent reference throughout disposal, so none of those obstacles exist for it.
 
 
-**The send happens before `releaseOwnership`.** At that point the parent still counts this child, so `stateOf(parent)` is `waiting` and the parent is structurally unable to be judged settled. Delivering after the release instead races a watcher that resumes one microtask later, finds itself childless and quiet, and disposes an Agent whose `cancel()` clears the very inbox the notice is sitting in. The failure mode is a silently missing message with no error anywhere.
+**The send happens before `releaseOwnership`.** At that point the parent's owned-child set still contains this child, so the settlement predicate cannot succeed. Delivering after the release instead races a watcher that resumes one microtask later, finds itself childless and quiet, and disposes an Agent whose `cancel()` clears the very inbox the notice is sitting in. The failure mode is a silently missing message with no error anywhere.
 
 
-**A resident parent receives it through `admitWaking`.** Registering the message id before the synchronous send is what keeps the window between `followup()` and the microtask that admits it from being read as quiescence. This is not belt-and-braces over the first rule: `Agent.status` folds context maintenance into `idle`, and a waking send behind maintenance only arms a deferred wake, so a parent compacting its context is judged quiet by both `status` and the owned-child set the moment the release lands.
+**A resident parent receives it through its private `SubagentInbox`.** The wrapper checks the Activation's closing promise immediately before the synchronous waking send, and the manager renews the wake generation before returning. The final settlement decision rechecks that generation, the Session sequence, the pending Inbox, and the owned-child set under the child lock, then claims the Agent's idle phase through `runMaintenance()` before closing admission. This is not redundant with the first rule: `Agent.status` folds context maintenance into `idle`, and a waking send behind maintenance only arms a deferred wake.
 
 
 Both rules are pinned by tests that fail when the ordering is reversed or the accounting removed.
 Both rules are pinned by tests that fail when the ordering is reversed or the accounting removed.
 
 

+ 2 - 2
.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.zh.md

@@ -26,9 +26,9 @@ Status: implemented
 
 
 外部 `ctx.on('subagent/end')` listener 看起来更解耦,但它是错的。`SubagentRunEndInfo` 不指名父级;该边触发时 child handle 已被 dispose,因此无法从中恢复父级;而唤醒父级自身结算 watcher 的所有权释放也已经执行过了。管理器在整个 dispose 过程中都持有父级引用,因此这些障碍对它都不存在。
 外部 `ctx.on('subagent/end')` listener 看起来更解耦,但它是错的。`SubagentRunEndInfo` 不指名父级;该边触发时 child handle 已被 dispose,因此无法从中恢复父级;而唤醒父级自身结算 watcher 的所有权释放也已经执行过了。管理器在整个 dispose 过程中都持有父级引用,因此这些障碍对它都不存在。
 
 
-**发送发生在 `releaseOwnership` 之前。** 此刻父级仍然计入这个 child,因此 `stateOf(parent)` 为 `waiting`,父级在结构上不可能被判定为已结算。改在释放之后投递,则会与一个在下一个 microtask 恢复的 watcher 竞争:它会发现自己没有 child 且处于静止,于是 dispose 一个 Agent,而该 Agent 的 `cancel()` 会清空正装着这条通知的那个 inbox。失效表现是一条静默丢失的消息,任何地方都不会报错。
+**发送发生在 `releaseOwnership` 之前。** 此刻 parent 的 owned-child set 仍然包含这个 child,因此结算判据不可能成立。改在释放之后投递,则会与一个在下一个 microtask 恢复的 watcher 竞争:它会发现自己没有 child 且处于静止,于是 dispose 一个 Agent,而该 Agent 的 `cancel()` 会清空正装着这条通知的那个 inbox。失效表现是一条静默丢失的消息,任何地方都不会报错。
 
 
-**驻留父级通过 `admitWaking` 接收它。** 在同步发送之前登记消息 id,正是让 `followup()` 与承认它的那个 microtask 之间的窗口不被读作静止的原因。这不是对第一条规则的多余保险:`Agent.status` 会把上下文维护折叠成 `idle`,而维护期间的唤醒发送只会预置一次延后唤醒,因此正在压缩上下文的父级,在所有权释放落地的那一刻会同时被 `status` 与已拥有 child 集合判定为静止
+**驻留 parent 通过私有 `SubagentInbox` 接收它。** 包装层会在同步唤醒发送前立即检查 Activation 的 closing promise,manager 则会在返回前更新 wake generation。最终结算决策会在 child lock 内重新检查该 generation、Session 序号、待处理 Inbox 与 owned-child set,再通过 `runMaintenance()` 占用 Agent 的 idle 阶段,然后关闭准入。这并非对第一条规则的重复保护:`Agent.status` 会把 context maintenance 折叠成 `idle`,而 maintenance 期间的唤醒发送只会预置一次延后唤醒。
 
 
 两条规则都有测试固定:把顺序反转或去掉记账,测试就会失败。
 两条规则都有测试固定:把顺序反转或去掉记账,测试就会失败。
 
 

+ 6 - 0
.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.md
+2026-08-27-continuable-subagent-human-inbox-control.md: cf5dfd070dfd600fb64bc529b4a1476a258c1181
+2026-08-27-continuable-subagent-human-inbox-control.zh.md: 081fb84f75afcc94339ae3bf627480081bc56d89

+ 51 - 0
.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.md

@@ -0,0 +1,51 @@
+# Agent Note: Human inbox controls for continuable subagents
+
+Status: implemented
+
+English | [中文](2026-08-27-continuable-subagent-human-inbox-control.zh.md)
+
+## Problem
+
+Continuable children use the same Agent loop and inbox as ordinary Agents, but the human delivery path exposed only FIFO follow-up. The Client discarded its existing Queue/Steer choice when it selected the dedicated subagent prompt Remote, and the generic Session ownership fence rejected every queue mutation for a subagent-owned identity. The browser therefore hid controls that the live child inbox already supported.
+
+Opening generic Session control indiscriminately would weaken the subagent ownership rule. Prompt delivery still needs exact live direct-parent authorization and cold-resume accounting, while queue mutation must reject one-shot, unknown, corrupt, and cold children. A valid continuable descriptor in the child's own log suffix identifies which live subagent-owned Sessions may use occurrence mutation. Settlement must also retain an idle Agent while a delivery a driver will claim is still pending, and must not tear down an Agent that claimed the idle phase for a maintenance task after `whenIdle()` resolved.
+
+## Decision
+
+A live continuable child exposes the ordinary human inbox controls without adding another queue, Remote endpoint, queue action, or Host-facing subagent operation. One-shot children remain read-only.
+
+The existing `SubagentPromptRequest` carries `delivery: 'queue' | 'steer'`. The Client forwards the mode already selected by `Session.prompt(content, mode)` through `subagent.prompt`. The Remote still requires the exact live direct parent and then uses one package-internal continuation-manager delivery operation. Queue calls `Agent.followup(message)`; steer calls `Agent.steer(message)`. Both paths share child locking, cold resume, final parent reauthorization, caller-signal cutoff, `MessageId` creation, rollback, and disposal-race handling. This human choice adds no public scheduling method or model tool; the separately owned `sendMessage()` and model-facing `send_message` operation keep their fixed adjacent-Agent Steer semantics.
+
+The browser gives a continuable child the ordinary busy Enter/Cmd+Enter Queue/Steer preference, QueueDock Edit/Remove/Steer actions, and empty-draft steer-all gesture. Send and Stop remain independent controls. Composer prompts still require the live parent because their Remote mints new admitted work. QueueDock mutations address already-live inbox work directly, so they remain available when the parent is offline; the parent-offline composer stays locked.
+
+The existing `session.updateQueue(itemId, action)` resolves the exact live Agent and admits a subagent-owned Session only when its current projected identity is continuable and the descriptor sequence belongs to the child's own non-seed suffix. A live one-shot Agent and a missing, inherited-only, or invalid identity retain the ownership failure. An absent Agent returns `queue-item-not-found` and does not cold-resume the child. The target Session id is sufficient human authority for a live inbox occurrence mutation; a parent address is not required. Edit and Remove retain their complete existing `nextTurn` and `nextStep` semantics, including plugin-injected context, while Steer requires a queued occurrence and an Agent that reports running when the command begins.
+
+The continuation manager keeps no second message-reservation state. One private `SubagentInbox` delegates Queue and Steer to the Agent inbox and owns the Activation's existing closing promise. Natural settlement waits for `Agent.whenIdle()`, an empty child Inbox, and disposal of every owned child. The manager confirms the Inbox, owned-child set, and wake generation under the child lock, then flushes final Session state while admission remains open. The final child-lock decision revalidates the Session sequence and the same residency facts, then synchronously starts an `Agent.runMaintenance()` task whose entry claims the idle phase and closes the wrapper in the same JavaScript turn. Every pending Inbox occurrence retains the Activation regardless of its delivery mode or provenance. Manager-owned deliveries, Inbox claims or discards, and owned-child release renew the wake generation. Direct Agent work accepted during the flush either changes the final Session or residency observation, remains active and prevents the final maintenance task from starting, or completes before revalidation.
+
+QueueDock Steer uses the Agent's best-effort delivery after the command admits a running queued occurrence. If the queued occurrence was claimed first, `queue-item-not-found` leaves its ordinary Queue delivery underway. If active cancellation wins during the synchronous transfer, Agent steering appends the message to `nextTurn`, latches a wake, and the Session command still succeeds. The selected message moves behind the remaining Queue in that fallback case. Newly composed Steer uses the same fallback and remains deliverable when it misses the nearest step.
+
+This decision partially supersedes the human-control exclusions in [Web subagent catalog and human continuation](2026-07-27-web-subagent-conversations.md), [Continuable subagents](2026-07-28-continuable-subagent-conversations.md), [Steer a queued Web message](../../archived/feature/2026-07-30-web-queue-steer-action.md), and [Steer the whole Web queue with an empty-draft Cmd/Ctrl+Enter](../../archived/feature/2026-08-06-web-queue-steer-all-gesture.md). The active records own catalog authorization and Activation lifecycle; the archived records preserve the original QueueDock Steer and gesture decisions.
+
+## Alternatives considered
+
+**Add `SubagentRuntime.steer()` and a new Remote.** Rejected because human prompt delivery already has a mode-bearing Client method and one authenticated Remote. A new public operation would expand both the service and model-adjacent surface without adding an execution primitive.
+
+**Add `subagents.updateQueue`.** Rejected because `session.updateQueue` already owns exact inbox occurrence mutation and its race failures. The projected continuable identity provides the narrow ownership-fence exception without adding another operation.
+
+**Route every subagent control through generic Session APIs.** Rejected because prompt and cancellation require subagent lineage authorization, cold-resume accounting, and dedicated failure mapping. Only live inbox occurrence mutation has enough target-local state to use the narrow ownership-fence exception.
+
+**Restrict continuable queue mutation to `nextTurn`.** Rejected because human inbox parity intentionally includes editing or removing pending steering and injected context. If a plugin needs a stronger transaction around its `nextStep` input, that protection belongs to the shared Agent inbox semantics rather than a subagent-only restriction.
+
+**Track waking work by `MessageId` and transfer that record across mutation.** Rejected because it duplicates the Inbox's pending set with a second activity ledger and couples residency to occurrence identity. `whenIdle()` waits for existing Agent activity, `Inbox.hasPending` conservatively retains every occurrence, the Activation generation invalidates stale observations, and the final maintenance task atomically joins idle ownership to admission closure. This choice can retain quiet injected context, but it avoids both an additional mutation protocol and silent loss of accepted steering.
+
+**Derive residency from `MessageSource.kind`, treating `plugin` as parked context.** Rejected because `kind` records who produced a message, not how it was delivered, and `MessageSourceMap` is merge-extensible. Plugins steer with a plugin source (`cordis-host-runner` failure reports, blocking Stop hooks) and hosts inject with non-plugin sources (`dsh-experimental-agent-team` quiet mail), so the correspondence fails in both directions. Treating all pending occurrences alike avoids that unsupported inference.
+
+## Consequences
+
+Continuable child conversations and ordinary Sessions share one human inbox interaction model and one Agent-loop queue. Human steering can affect a resident or cold-resumed child without changing public model controls. QueueDock remains useful for a live child after its parent goes offline, while new messages continue to respect direct-parent authorization.
+
+The generic Session command has one narrow ownership-fence exception for a live subagent-owned Agent with a valid own-suffix continuable identity. Because the operation addresses either inbox destination, a caller that knows a pending `MessageId` can edit or remove plugin-supplied next-step input, exactly as on an ordinary Session. QueueDock renders only `queued`-placement rows, so no browser gesture reaches that input; an edit there also keeps the original producer's `MessageSource`, which would attribute human text to that producer.
+
+Inbox notifications retain their occurrence semantics and do not carry continuation residency. Claim and discard notifications only wake settlement after pending work changes; `whenIdle()`, the final idle-phase maintenance task, `Inbox.hasPending`, the owned-child set, the Activation generation, and the Session sequence decide whether disposal is safe without depending on scheduler ordering, message identity, or provenance. The final flush precedes the closing cutoff, so a detached hook, job completion, or direct Agent delivery accepted during that await invalidates the observation instead of being stopped by the resulting disposal. Maintenance that remains active prevents the final task from claiming the idle phase; maintenance that starts and finishes during the flush has completed before disposal. A child left holding only injected context remains resident even though no driver is obliged to claim it; without a later waking delivery, queue removal, or manager teardown, that child and its live ancestors can remain resident for the process lifetime. A replayed Inbox follows the same conservative rule without reconstructing how each pending message was delivered.
+
+Model-side scheduling remains fixed rather than caller-selectable. The adjacent-Agent `send_message` tool always uses Steer, while only the browser human path chooses Queue or Steer.

+ 51 - 0
.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.zh.md

@@ -0,0 +1,51 @@
+# Agent Note: 可继续 subagent 的人类 inbox 控制
+
+Status: implemented
+
+[English](2026-08-27-continuable-subagent-human-inbox-control.md) | 中文
+
+## 问题
+
+可继续子级与普通 Agent 使用相同的 agent loop(智能体循环)和 inbox,但人类投递路径只公开 FIFO 后续轮次。Client 选择专用 subagent prompt Remote 时会丢弃既有的 Queue/Steer 选择,通用 Session ownership fence 又拒绝 subagent 所有身份的全部 queue 变更。因此,浏览器隐藏了在线子级 inbox 已经支持的控制。
+
+无差别开放通用 Session 控制会削弱 subagent 所有权规则。Prompt 投递仍需要确切在线直接父级鉴权与冷恢复记账,而 queue 变更必须拒绝一次性、未知、损坏和冷子级。child 自身 log suffix 中的有效 continuable descriptor 可标识哪些在线 subagent-owned Session 能使用 occurrence mutation。Settlement 还必须在 idle Agent 仍有会被 driver 认领的待投递工作时保留该 Agent,也不得拆除在 `whenIdle()` 兑现后才占用 idle 阶段执行 maintenance 任务的 Agent。
+
+## 决策
+
+在线可继续子级公开普通的人类 inbox 控制,不增加另一套 queue、Remote endpoint、queue action 或面向 Host 的 subagent 操作。一次性子级继续只读。
+
+现有 `SubagentPromptRequest` 携带 `delivery: 'queue' | 'steer'`。Client 把 `Session.prompt(content, mode)` 已选出的 mode 经 `subagent.prompt` 原样转发。Remote 仍要求确切在线直接父级,随后使用一个包内 continuation manager 投递操作。Queue 调用 `Agent.followup(message)`;steer 调用 `Agent.steer(message)`。两条路径共享 child lock、冷恢复、最终父级重新鉴权、调用方 signal 截止、`MessageId` 创建、回滚与 dispose 竞态处理。该人类选择不新增公开调度方法或模型工具;由其他决策拥有的 `sendMessage()` 与面向模型的 `send_message` 操作保留固定的相邻 Agent Steer 语义。
+
+浏览器为可继续子级提供普通的繁忙态 Enter/Cmd+Enter Queue/Steer 偏好、QueueDock Edit/Remove/Steer 操作,以及空草稿 steer-all 手势。Send 与 Stop 继续是独立控制。Composer prompt 会创建新的已准入工作,因此仍要求在线父级。QueueDock 变更直接寻址已经在线的 inbox 工作,所以父级离线时仍可使用;父级离线的 composer 继续锁定。
+
+现有 `session.updateQueue(itemId, action)` 会解析确切在线 Agent,并且只有 subagent-owned Session 的当前 projected identity 为 continuable、descriptor 序号属于 child 自身的非 seed suffix 时才会准入。在线 one-shot Agent 以及缺失、仅继承或无效的 identity 都会继续触发所有权失败。Agent 不存在时返回 `queue-item-not-found`,且不会冷恢复子级。对在线 inbox occurrence 变更而言,目标 Session id 已是充分的人类权限;无需 parent 地址。Edit 与 Remove 保留既有完整 `nextTurn` 和 `nextStep` 语义,包括插件注入的 context;Steer 要求排队 occurrence,且 command 开始时 Agent 必须报告 running。
+
+Continuation manager 不保留第二套消息 reservation 状态。一个私有 `SubagentInbox` 会把 Queue 与 Steer 委托给 Agent inbox,并持有 Activation 既有的 closing promise。自然结算会等待 `Agent.whenIdle()`、child Inbox 为空以及所拥有的每个子级完成 dispose。管理器会在 child lock 内确认 Inbox、owned-child set 与 wake generation,再在准入保持开放时 flush 最终 Session 状态。最终 child-lock 决策会重新验证 Session 序号与相同的驻留事实,然后同步启动一个 `Agent.runMaintenance()` 任务;该任务的入口会占用 idle 阶段,并在同一个 JavaScript turn 内关闭包装层。每个待处理 Inbox occurrence 都会保留 Activation,无论其投递模式或来源如何。由 manager 所有的投递、Inbox claim 或 discard,以及所拥有子级的释放都会更新 wake generation。flush 期间直接接受的 Agent 工作要么改变最终 Session 或驻留观察,要么保持活跃并阻止最终 maintenance 任务启动,要么在重验前完成。
+
+QueueDock Steer 在 command 准入一个正在运行的排队 occurrence 后,采用 Agent 的 best-effort 投递。如果排队 occurrence 先被 claim,`queue-item-not-found` 表示其普通 Queue 投递已经开始。如果活跃取消在同步转移期间先发生,Agent steering 会把消息追加到 `nextTurn`、锁存唤醒,Session command 仍然成功。在该 fallback 情况下,选中消息会移到 Queue 剩余项之后。新组合的 Steer 使用同样的 fallback,错过最近步骤时仍保证可投递。
+
+本决策部分取代 [Web subagent 目录与人类 continuation](2026-07-27-web-subagent-conversations.zh.md)、[可继续 subagent](2026-07-28-continuable-subagent-conversations.zh.md)、[Steer Web 已排队消息](../../archived/feature/2026-07-30-web-queue-steer-action.md)和[用空草稿 Cmd/Ctrl+Enter steer 整个 Web queue](../../archived/feature/2026-08-06-web-queue-steer-all-gesture.md)中的人类控制排除项。活跃记录拥有目录鉴权与 Activation 生命周期;归档记录保留最初的 QueueDock Steer 与手势决策。
+
+## 考虑过的替代方案
+
+**新增 `SubagentRuntime.steer()` 与 Remote。** 拒绝,因为人类 prompt 投递已经拥有带 mode 的 Client 方法和一个已鉴权 Remote。新的公开操作会扩大 service 与模型相邻接口,却不增加执行原语。
+
+**新增 `subagents.updateQueue`。** 拒绝,因为 `session.updateQueue` 已经拥有准确 inbox occurrence 变更及其竞态失败。Projected continuable identity 提供狭窄的 ownership-fence 例外,无需新增操作。
+
+**把所有 subagent 控制都路由到通用 Session API。** 拒绝,因为 prompt 与取消需要 subagent 血缘鉴权、冷恢复记账与专用失败映射。只有在线 inbox occurrence 变更拥有足够的目标本地状态,可使用狭窄的 ownership-fence 例外。
+
+**把可继续 queue 变更限制在 `nextTurn`。** 拒绝,因为人类 inbox 对齐有意包括编辑或删除待处理 steering 与注入 context。如果插件需要围绕其 `nextStep` 输入建立更强事务,该保护应属于共享 Agent inbox 语义,而非 subagent 专属限制。
+
+**按 `MessageId` 跟踪唤醒工作,并在 mutation 中转移该记录。** 拒绝,因为这会用第二套活动账本重复 Inbox 的待处理集合,并让驻留依赖 occurrence 身份。`whenIdle()` 会等待既有 Agent 活动,`Inbox.hasPending` 保守地保留每个 occurrence,Activation generation 会让过期观察失效,而最终 maintenance 任务则以原子方式衔接 idle ownership 与准入关闭。这项选择可能保留静默注入的 context,但既避免额外的 mutation 协议,也避免静默丢失已接受的 steering。
+
+**用 `MessageSource.kind` 推导驻留,把 `plugin` 视为停放 context。** 拒绝,因为 `kind` 记录的是消息由谁产生,而非如何投递,且 `MessageSourceMap` 可合并扩展。插件会以 plugin 来源 steer(`cordis-host-runner` 的失败报告、阻断式 Stop hook),host 也会以非 plugin 来源 inject(`dsh-experimental-agent-team` 的静默邮件),因此该对应关系在两个方向上都不成立。统一对待所有待处理 occurrence 可以避免这种没有依据的推断。
+
+## 结果
+
+可继续子级会话与普通 Session 共享一套人类 inbox 交互模型和一套 Agent-loop queue。人类 steering 可以影响驻留或冷恢复的子级,而不改变公开模型控制。父级离线后,QueueDock 对在线子级仍有用;新消息则继续遵守直接父级鉴权。
+
+通用 Session command 为拥有有效自身 suffix continuable identity 的在线 subagent-owned Agent 提供一个狭窄的 ownership-fence 例外。因为该操作可寻址两个 inbox 目标,知道待处理 `MessageId` 的调用方可以像操作普通 Session 一样,编辑或删除插件提供的 next-step 输入。QueueDock 只渲染 `queued` placement 的行,因此没有浏览器手势能到达该输入;在那里编辑还会保留原产出方的 `MessageSource`,从而把人类文本归属给该产出方。
+
+Inbox notification 保留 occurrence 语义,不携带 continuation 驻留状态。Claim 与 discard notification 只负责在待处理工作变化后唤醒 settlement;`whenIdle()`、最终 idle 阶段 maintenance 任务、`Inbox.hasPending`、owned-child set、Activation generation 与 Session 序号无需依赖调度顺序、消息身份或来源即可决定何时安全 dispose。最终 flush 位于 closing cutoff 之前,因此 detached hook、job completion 或直接 Agent 投递只要在该 await 期间被接受,就会让观察失效,而不会被随后发生的 dispose 停止。仍然活跃的 maintenance 会阻止最终任务占用 idle 阶段;在 flush 期间开始并结束的 maintenance 已在 dispose 前完成。仅持有被注入 context 的 child 即使没有 driver 必须认领它,也会保持驻留;如果之后没有唤醒投递、queue removal 或 manager teardown,该 child 及其在线祖先可以在进程生命周期内一直驻留。重放出的 Inbox 遵循同一条保守规则,无需重建每条待处理消息的投递方式。
+
+模型侧调度保持固定,不由调用方选择。相邻 Agent 的 `send_message` 工具始终使用 Steer,只有浏览器人类路径选择 Queue 或 Steer。

+ 4 - 4
apps/web/tests/steering.e2e.ts

@@ -110,8 +110,8 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
       { timeout: 10_000 },
       { timeout: 10_000 },
     ).toBe(true)
     ).toBe(true)
 
 
-    // Enter remains the Queue gesture. The row action then atomically moves
-    // this exact occurrence into the current turn's steering outbox.
+    // Enter remains the Queue gesture. In this live window the row action
+    // atomically moves this exact occurrence into the current turn's steering outbox.
     await page.locator('[data-composer-input][contenteditable="true"]').first().waitFor({ timeout: 10_000 })
     await page.locator('[data-composer-input][contenteditable="true"]').first().waitFor({ timeout: 10_000 })
     await input.fill(STEER)
     await input.fill(STEER)
     await input.press('Enter')
     await input.press('Enter')
@@ -121,8 +121,8 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
     await expect.poll(() => steerButton.isEnabled(), { timeout: 10_000 }).toBe(true)
     await expect.poll(() => steerButton.isEnabled(), { timeout: 10_000 }).toBe(true)
     await steerButton.click({ timeout: 10_000 })
     await steerButton.click({ timeout: 10_000 })
     const pendingSteering = page.locator('[data-pending-steering]').filter({ hasText: STEER })
     const pendingSteering = page.locator('[data-pending-steering]').filter({ hasText: STEER })
-    // A timeout while the Queue row remains means strict steer lost to a
-    // closing window (`steer-unavailable`); inspect replay pacing first.
+    // A timeout while the Queue row remains means the command observed a
+    // stopped Agent (`steer-unavailable`); inspect replay pacing first.
     await pendingSteering.waitFor({ timeout: 10_000 })
     await pendingSteering.waitFor({ timeout: 10_000 })
 
 
     // The blocked composer keeps steering pending long enough to observe the
     // The blocked composer keeps steering pending long enough to observe the

+ 35 - 11
apps/web/tests/subagent-interrupt-ui.e2e.ts

@@ -37,6 +37,7 @@ const INITIAL = 'Explain event sourcing in one sentence.'
 const REARM = 'Keep working until I stop you again.'
 const REARM = 'Keep working until I stop you again.'
 const REARM_WAKE = 'Start that queued work now.'
 const REARM_WAKE = 'Start that queued work now.'
 const FOLLOWUP = 'Now give the same explanation to a human reader.'
 const FOLLOWUP = 'Now give the same explanation to a human reader.'
+const EDITED_FOLLOWUP = 'Explain the same idea for a human reader.'
 const WAKING = 'And add one concrete example.'
 const WAKING = 'And add one concrete example.'
 const REARMED_ANSWER = 're-armed setup answer'
 const REARMED_ANSWER = 're-armed setup answer'
 const PARKED_ANSWER = 'parked follow-up answer'
 const PARKED_ANSWER = 'parked follow-up answer'
@@ -213,22 +214,26 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co
       const send = page.getByRole('button', { name: 'Send message' })
       const send = page.getByRole('button', { name: 'Send message' })
       expect(await send.count()).toBe(1)
       expect(await send.count()).toBe(1)
       expect(await send.isDisabled()).toBe(true)
       expect(await send.isDisabled()).toBe(true)
-      await compareOrRefreshGolden(
-        OFFLINE_COMPOSER_EXPECTED,
-        await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd),
-        MODE,
-      )
-
       // Keep the continuable Activation resident after this first abort. The
       // Keep the continuable Activation resident after this first abort. The
-      // direct setup queue does not change the parent-offline UI contract: its
-      // input and Send remain disabled throughout the exercised browser path.
+      // direct setup queue also proves the ordinary row controls remain
+      // available while this parent-offline composer cannot submit new input.
       await scaffold.ctx.subagents.prompt({
       await scaffold.ctx.subagents.prompt({
         requestId: 'interrupt-ui-rearm' as SubagentPromptRequestId,
         requestId: 'interrupt-ui-rearm' as SubagentPromptRequestId,
         parentSessionId: parent.id,
         parentSessionId: parent.id,
         childSessionId: childId,
         childSessionId: childId,
         mode: 'continuable',
         mode: 'continuable',
+        delivery: 'queue',
         content: [{ type: 'text', text: REARM }],
         content: [{ type: 'text', text: REARM }],
       }, new AbortController().signal)
       }, new AbortController().signal)
+      await page.getByRole('button', { name: 'Edit queued message' }).waitFor({ timeout: 15_000 })
+      expect(await page.getByRole('button', { name: 'Remove queued message' }).count()).toBe(1)
+      expect(await page.getByRole('button', { name: 'Steer queued message' }).count()).toBe(1)
+      await compareOrRefreshGolden(
+        OFFLINE_COMPOSER_EXPECTED,
+        await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd),
+        MODE,
+      )
+
       const aborted = waitForAbortedTurn(scaffold, childId)
       const aborted = waitForAbortedTurn(scaffold, childId)
       const interruptResponse = page.waitForResponse(response =>
       const interruptResponse = page.waitForResponse(response =>
         new URL(response.url()).pathname === '/api/subagents/interruptByParent')
         new URL(response.url()).pathname === '/api/subagents/interruptByParent')
@@ -247,6 +252,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co
         parentSessionId: parent.id,
         parentSessionId: parent.id,
         childSessionId: childId,
         childSessionId: childId,
         mode: 'continuable',
         mode: 'continuable',
+        delivery: 'queue',
         content: [{ type: 'text', text: REARM_WAKE }],
         content: [{ type: 'text', text: REARM_WAKE }],
       }, new AbortController().signal)
       }, new AbortController().signal)
       await waitFor(() => existsSync(rearmedReadyFile), 'the re-armed child turn to open')
       await waitFor(() => existsSync(rearmedReadyFile), 'the re-armed child turn to open')
@@ -263,8 +269,13 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co
       .getByRole('button').first().click()
       .getByRole('button').first().click()
     await page.getByRole('button', { name: /1 subagent/ }).click()
     await page.getByRole('button', { name: /1 subagent/ }).click()
     await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
     await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
-    const input = page.getByRole('textbox', { name: 'Message or run a task... / commands, @ files or sessions' })
-    await input.waitFor({ timeout: 15_000 })
+    // A live continuable child advertises the ordinary steer-all gesture, so
+    // that placeholder is the composer's accessible name in this window. It
+    // changes back as the queue drains, so later interactions address the
+    // stable composer node instead.
+    await page.getByRole('textbox', { name: 'Cmd/Ctrl+Enter steers all queued messages' })
+      .waitFor({ timeout: 15_000 })
+    const input = page.locator('[data-composer-input]').first()
     expect(await input.isDisabled()).toBe(false)
     expect(await input.isDisabled()).toBe(false)
 
 
     // Queue a follow-up through Send while independent Stop remains available.
     // Queue a follow-up through Send while independent Stop remains available.
@@ -275,6 +286,19 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co
     expect(((await (await promptResponse).json()) as { result: { ok: boolean } }).result)
     expect(((await (await promptResponse).json()) as { result: { ok: boolean } }).result)
       .toMatchObject({ ok: true })
       .toMatchObject({ ok: true })
 
 
+    await page.getByRole('button', { name: '2 queued messages' }).click()
+    const followupRow = page.locator('[data-queue-dock] li', { hasText: FOLLOWUP })
+    await followupRow.getByRole('button', { name: 'Edit queued message' }).click()
+    const editor = page.getByRole('textbox', { name: 'Edit queued message' })
+    await editor.fill(EDITED_FOLLOWUP)
+    const updateResponse = page.waitForResponse(response =>
+      new URL(response.url()).pathname === '/api/session/updateQueue')
+    await page.getByRole('button', { name: 'Save queued message' }).click()
+    expect(((await (await updateResponse).json()) as { result: { ok: boolean } }).result)
+      .toMatchObject({ ok: true })
+    await page.getByText(EDITED_FOLLOWUP, { exact: true }).waitFor()
+    expect(apiCalls.filter(path => path === '/api/subagents/updateQueue')).toEqual([])
+
     const aborted = waitForAbortedTurn(scaffold, childId)
     const aborted = waitForAbortedTurn(scaffold, childId)
     const stop = page.getByRole('button', { name: 'Stop generating' })
     const stop = page.getByRole('button', { name: 'Stop generating' })
     expect(await stop.count()).toBe(1)
     expect(await stop.count()).toBe(1)
@@ -314,7 +338,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co
       : [])
       : [])
     expect(userTexts[0]).toBe(INITIAL)
     expect(userTexts[0]).toBe(INITIAL)
     expect(userTexts[1]).toMatch(/^Your parent agent id is .+send_message\(\{ agent_id: /)
     expect(userTexts[1]).toMatch(/^Your parent agent id is .+send_message\(\{ agent_id: /)
-    expect(userTexts.slice(2)).toEqual([REARM, REARM_WAKE, FOLLOWUP, WAKING])
+    expect(userTexts.slice(2)).toEqual([REARM, REARM_WAKE, EDITED_FOLLOWUP, WAKING])
     const turnEndKinds = events
     const turnEndKinds = events
       .filter(event => event.type === 'turn/end')
       .filter(event => event.type === 'turn/end')
       .map(event => event.data.reason.kind)
       .map(event => event.data.reason.kind)

+ 2 - 0
apps/web/tests/subagent-interrupt.e2e.ts

@@ -136,6 +136,7 @@ describe.skipIf(MODE === 'record')('web e2e: subagents/interruptByParent over th
         parentSessionId: parentId,
         parentSessionId: parentId,
         childSessionId: childId,
         childSessionId: childId,
         mode: 'continuable',
         mode: 'continuable',
+        delivery: 'queue',
         content: [{ type: 'text', text: FOLLOWUP }],
         content: [{ type: 'text', text: FOLLOWUP }],
       },
       },
     })
     })
@@ -170,6 +171,7 @@ describe.skipIf(MODE === 'record')('web e2e: subagents/interruptByParent over th
         parentSessionId: parentId,
         parentSessionId: parentId,
         childSessionId: childId,
         childSessionId: childId,
         mode: 'continuable',
         mode: 'continuable',
+        delivery: 'queue',
         content: [{ type: 'text', text: WAKING }],
         content: [{ type: 'text', text: WAKING }],
       },
       },
     })
     })

+ 1 - 1
docs/event-producer-consumer.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
 #   pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
-event-producer-consumer.md: ae4119a08d4150a9d3284e6fbcfb33ba7207923a
+event-producer-consumer.md: 7cecb1f362c311cf9b7b617466c1eaa06721a05e
 event-producer-consumer.zh.md: 57af4848a79065cc4ca65d6f56f4d543ec979441
 event-producer-consumer.zh.md: 57af4848a79065cc4ca65d6f56f4d543ec979441

+ 4 - 4
docs/event-producer-consumer.md

@@ -54,10 +54,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` |
 | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` |
 | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
 | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
 | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:298`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
 | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:298`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
-| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:172`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) |
-| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:146`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:152`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:163`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) |
+| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:168`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) |
+| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:142`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:148`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:159`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) |
 | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`session-reference`](../packages/context/session-reference), [`system-prompt`](../packages/core/system-prompt) |
 | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`session-reference`](../packages/context/session-reference), [`system-prompt`](../packages/core/system-prompt) |
 | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
 | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
 | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
 | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |

+ 2 - 2
docs/module-graph.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/module-graph.md
 #   pnpm run verify-translation-pairing --write docs/module-graph.md
-module-graph.md: 5307a62360f2c01d03df4693081c02c5d6e56a39
-module-graph.zh.md: fa6e5b3cd98e3dc80aad906d01d1d1a07ef91842
+module-graph.md: 83b68cf3398e8f24d0332dd572679efe8fa75ceb
+module-graph.zh.md: b680094f342034e21a25f22f35404b2865d4318e

+ 2 - 1
docs/module-graph.md

@@ -1075,6 +1075,7 @@ flowchart TD
   pkg_api_session_controller --> pkg_typert_protocol
   pkg_api_session_controller --> pkg_typert_protocol
   pkg_api_session_controller --> pkg_typert_registry
   pkg_api_session_controller --> pkg_typert_registry
   pkg_api_session_controller --> pkg_util_time
   pkg_api_session_controller --> pkg_util_time
+  pkg_api_session_controller --> pkg_util_values
   pkg_api_session_controller --> pkg_util_workspace_path
   pkg_api_session_controller --> pkg_util_workspace_path
   pkg_api_session_controller --> pkg_workspace
   pkg_api_session_controller --> pkg_workspace
   pkg_experimental_agent_team --> pkg_agent
   pkg_experimental_agent_team --> pkg_agent
@@ -1401,7 +1402,7 @@ flowchart TD
 | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
 | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
 | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
 | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
-| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) |
+| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) |
 | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) |
 | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) |
 | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
 | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
 | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
 | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |

+ 2 - 1
docs/module-graph.zh.md

@@ -1077,6 +1077,7 @@ flowchart TD
   pkg_api_session_controller --> pkg_typert_protocol
   pkg_api_session_controller --> pkg_typert_protocol
   pkg_api_session_controller --> pkg_typert_registry
   pkg_api_session_controller --> pkg_typert_registry
   pkg_api_session_controller --> pkg_util_time
   pkg_api_session_controller --> pkg_util_time
+  pkg_api_session_controller --> pkg_util_values
   pkg_api_session_controller --> pkg_util_workspace_path
   pkg_api_session_controller --> pkg_util_workspace_path
   pkg_api_session_controller --> pkg_workspace
   pkg_api_session_controller --> pkg_workspace
   pkg_experimental_agent_team --> pkg_agent
   pkg_experimental_agent_team --> pkg_agent
@@ -1403,7 +1404,7 @@ flowchart TD
 | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
 | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
 | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
 | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
-| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) |
+| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) |
 | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) |
 | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) |
 | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
 | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
 | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
 | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |

+ 2 - 2
docs/subsystems/subagent.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/subagent.md
 #   pnpm run verify-translation-pairing --write docs/subsystems/subagent.md
-subagent.md: 616300f92ffa827f14c4780a7648f60f52df56ad
-subagent.zh.md: 059dc4af988ad5ce66bed64827b72449f5a781ca
+subagent.md: cf711185d02808f70a71a46c6d6c1af4da4a7334
+subagent.zh.md: e97b4ab965d279f04ddda5e8ae66621b0198a5cd

+ 11 - 8
docs/subsystems/subagent.md

@@ -141,13 +141,15 @@ persisted Session
 | `waiting` | wake and steer the same Activation |
 | `waiting` | wake and steer the same Activation |
 | no Activation | cold-resume a new Activation, then steer it |
 | no Activation | cold-resume a new Activation, then steer it |
 
 
-`running` means the Agent has an active admission or turn, or waking inbox work; `waiting` means it is quiescent but still owns at least one child Activation that has not completed disposal; `settled` means quiescent with every owned child disposed, at which point the manager disposes the [`AgentHandle`](core.md#creation-and-ownership) and removes the Activation. The manager derives these internal conditions from Agent quiescence and the owned-child set rather than maintaining a second execution state machine.
+`running` means the Agent has an active driver or maintenance task; `waiting` means no Agent activity is active but its Inbox is nonempty or it owns at least one child Activation that has not completed disposal; `settled` means no Agent activity is active, the Inbox is empty, and every owned child is disposed, at which point the manager disposes the [`AgentHandle`](core.md#creation-and-ownership) and removes the Activation. The manager derives these internal conditions from `Agent.whenIdle()`, `Agent.inbox.hasPending`, the owned-child set, and an Activation generation that invalidates stale observations, rather than maintaining a second execution state machine. After the final Session flush, the child-lock decision uses the synchronous task entry of `Agent.runMaintenance()` to claim the idle phase and close admission in the same JavaScript turn. This conservative rule does not distinguish delivery modes: context parked by `Agent.inject()` can keep an idle Activation and its live ancestors resident until a waking delivery claims it, a queue mutation removes it, or manager teardown discards it.
 
 
-The Agent inbox is the only queue. Every Agent message uses `Agent.steer()`: an idle target starts a turn, while a running target claims it at the nearest step boundary. Successful delivery returns the accepted `MessageId`; the existing `agent/inbox/inserted`, `agent/inbox/claimed`, and `agent/inbox/discarded` events remain the message-lifecycle observations, and the continuation layer defines no subagent-specific delivery route.
+The Agent inbox is the only queue. Every Agent message uses `Agent.steer()`: an idle target starts a turn, while a running target claims it at the nearest step boundary. The browser `subagent.prompt` Remote separately carries `delivery: 'queue' | 'steer'` through the same internal admission path; Queue opens a later FIFO turn, while Steer retains the Agent loop's best-effort nearest-step behavior and the message's human source. Successful delivery returns the accepted `MessageId`; the existing `agent/inbox/inserted`, `agent/inbox/claimed`, and `agent/inbox/discarded` events remain the message-lifecycle observations, and the continuation layer defines no second queue.
 
 
 Authority comes from the exact live sender. Parent-to-child delivery requires the target's `SessionHeader.parentSession` to name the sender; child-to-parent delivery requires the sender's resident Activation to name the target. Siblings, ancestors beyond one edge, self-targets, stale Agent objects, and one-shot children are rejected. Each accepted message is framed as `Agent <sender-id> sent a message:` and records `AgentMessageSource`; provenance records the sender but grants no authority.
 Authority comes from the exact live sender. Parent-to-child delivery requires the target's `SessionHeader.parentSession` to name the sender; child-to-parent delivery requires the sender's resident Activation to name the target. Siblings, ancestors beyond one edge, self-targets, stale Agent objects, and one-shot children are rejected. Each accepted message is framed as `Agent <sender-id> sent a message:` and records `AgentMessageSource`; provenance records the sender but grants no authority.
 
 
-For `startContinuable()` and `sendMessage()`, the caller signal owns lookup, materialization, and admission only until inbox acceptance. Afterwards the manager owns the Activation independently: later caller cancellation neither cancels the accepted turn nor disposes the child. Human browser prompts remain a separate private Queue adapter and therefore still produce distinct FIFO turns.
+For `startContinuable()`, `sendMessage()`, and browser prompt delivery, the caller signal owns lookup, materialization, and admission only until inbox acceptance. Afterwards the manager owns the Activation independently: later caller cancellation neither cancels the accepted turn nor disposes the child. The public subagent service exposes no caller-selected Agent-message scheduling; browser human Queue and Steer remain internal adapter choices.
+
+Live queue occurrence mutation remains in the Session domain. `session.updateQueue` admits ordinary Edit, Remove, and QueueDock Steer for a live subagent-owned Agent only when its current projected identity is continuable and its descriptor sequence is in that child's own non-seed suffix. The identity projection folds descriptors last-wins so a child descriptor supersedes descriptors retained from fork lineage; the own-suffix sequence check prevents a seed-only ancestor identity from authorizing mutation. One-shot, missing, unknown, corrupt, or cold children remain rejected, and queue mutation never cold-resumes a child. The target Session id is the human authority for these mutations, including pending `nextStep` steering or injected context. Steer requires a queued `MessageId` and an Agent that reports running when the command begins; cancellation after admission uses the Agent's accepted waking `nextTurn` fallback. Edit rewrites content under the same `MessageId`, and both Edit and Steer complete their Inbox work synchronously, so settlement observes only the final state. `agent/inbox/claimed` and `agent/inbox/discarded` wake the watcher to re-read whether any pending occurrence remains; this lets direct Agent delivery resume parked work and lets removing the last parked occurrence settle an idle child. The [human inbox-control Agent Note](../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.md) owns these semantics.
 
 
 `SubagentRuntime.interrupt(targetSessionId, authority)` is the one public stop: it authorizes synchronously, issues `Agent.cancel(cause, { keepInbox: true })` on the live target, and returns without awaiting quiescence. The Activation, its unclaimed pending inbox work, and published descendants are untouched; work already claimed into the interrupted turn is not requeued. Once the interrupted driver is idle, a waking send resumes the parked FIFO queue. An absent target — unknown, one-shot, or already settled — and a manager-less composition are accepted no-ops. For a live target, a mismatched parent address or caller outside its live ancestry rejects with `UNAUTHORIZED`; stale ancestor objects and self-targeting ancestor requests reject before target lookup.
 `SubagentRuntime.interrupt(targetSessionId, authority)` is the one public stop: it authorizes synchronously, issues `Agent.cancel(cause, { keepInbox: true })` on the live target, and returns without awaiting quiescence. The Activation, its unclaimed pending inbox work, and published descendants are untouched; work already claimed into the interrupted turn is not requeued. Once the interrupted driver is idle, a waking send resumes the parked FIFO queue. An absent target — unknown, one-shot, or already settled — and a manager-less composition are accepted no-ops. For a live target, a mismatched parent address or caller outside its live ancestry rejects with `UNAUTHORIZED`; stale ancestor objects and self-targeting ancestor requests reject before target lookup.
 
 
@@ -162,7 +164,7 @@ type SubagentInterruptAuthority =
   | { readonly kind: 'ancestor'; readonly agent: Agent }
   | { readonly kind: 'ancestor'; readonly agent: Agent }
 ```
 ```
 
 
-Every Activation owns its `AgentHandle` and an `ownedChildren: Set<SessionId>`; because one Session has at most one live Activation, the child Session id identifies the live child without another runtime-incarnation reference. Starting a child or submitting parent-originated work registers the child in a continuation-managed parent's set before the child can run, and that parent cannot settle while the set is non-empty. A top-level or other non-continuation Agent has no Activation and stays outside the waiting graph. Child release happens only after the child Agent is quiescent, every child of that child is disposed, the best-effort final session flush settles, and the child's `AgentHandle` completes disposal.
+Every Activation owns its `AgentHandle` and an `ownedChildren: Set<SessionId>`; because one Session has at most one live Activation, the child Session id identifies the live child without another runtime-incarnation reference. Starting a child or submitting parent-originated work registers the child in a continuation-managed parent's set before the child can run, and that parent cannot settle while the set is non-empty. A top-level or other non-continuation Agent has no Activation and stays outside the waiting graph. Child release happens only after the child has no active Agent work, its Inbox is empty, every child of that child is disposed, the best-effort final session flush settles, and the child's `AgentHandle` completes disposal.
 
 
 Final settlement awaits `ctx.sessions.flush(session)` but ignores its participation boolean because an arbitrary listener cannot prove that a persistence backend stored the state. Rejection is logged without failing the Activation, and the manager still disposes the handle and releases ownership; the persisted child state may then be missing or stale on a later resume. Manager unload invokes an internal manager-wide drain that closes admission and disposes every live forest; `drainContinuableDescendants(parents)` closes admission only below exact live host-owned Agents and disposes their continuable descendants while unrelated forests remain live. Both await already-admitted materializations in their scope, propagate cancellation top-down, release handles child-first, and await every selected branch despite individual failures. Durable child Sessions survive that process-local teardown.
 Final settlement awaits `ctx.sessions.flush(session)` but ignores its participation boolean because an arbitrary listener cannot prove that a persistence backend stored the state. Rejection is logged without failing the Activation, and the manager still disposes the handle and releases ownership; the persisted child state may then be missing or stale on a later resume. Manager unload invokes an internal manager-wide drain that closes admission and disposes every live forest; `drainContinuableDescendants(parents)` closes admission only below exact live host-owned Agents and disposes their continuable descendants while unrelated forests remain live. Both await already-admitted materializations in their scope, propagate cancellation top-down, release handles child-first, and await every selected branch despite individual failures. Durable child Sessions survive that process-local teardown.
 
 
@@ -195,7 +197,7 @@ interface ContinuableStart {
 }
 }
 ```
 ```
 
 
-When a resident Activation settles, the manager delivers one notice to the child's durable direct parent describing how that epoch ended and carrying its final assistant content. That delivery is unconditional for every child whose id a caller received, happens before the ownership release that would let the parent be judged settled, and reaches a resident parent through the same waking-admission accounting as an Agent message. A parent whose own lineage is already tearing down receives it without a wake, because waking a quiescent Agent starts a turn rather than queueing work. Its provenance is a distinct kind so a transcript never presents a runtime account as something the child wrote.
+When a resident Activation settles, the manager delivers one notice to the child's durable direct parent describing how that epoch ended and carrying its final assistant content. That delivery is unconditional for every child whose id a caller received, happens before the ownership release that would let the parent be judged settled, and reaches a resident parent through the same waking Agent delivery as an Agent message. A parent whose own lineage is already tearing down receives it without a wake, because waking an idle Agent starts a turn rather than queueing work. Its provenance is a distinct kind so a transcript never presents a runtime account as something the child wrote.
 
 
 ```ts type-equiv
 ```ts type-equiv
 /**
 /**
@@ -611,11 +613,12 @@ listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise<Subagen
  * Deliver one browser-authored message to a continuable child through the
  * Deliver one browser-authored message to a continuable child through the
  * exact live direct parent, retaining the caller-minted request identity and
  * exact live direct parent, retaining the caller-minted request identity and
  * validated browser zone on the accepted message. Success identifies the
  * validated browser zone on the accepted message. Success identifies the
- * message the child's FIFO inbox accepted; later execution is independent of
- * this call.
+ * message the child's inbox accepted; later execution is independent of this
+ * call. Queue delivery targets a later turn; steer delivery targets the
+ * nearest step and retains the Agent loop's best-effort fallback semantics.
  * Image parts are admitted and persisted through the attachment store
  * Image parts are admitted and persisted through the attachment store
  * before delivery, and the child's model must accept image input.
  * before delivery, and the child's model must accept image input.
- * @param request - durable address, minted identity, content, and optional browser zone.
+ * @param request - durable address, delivery, minted identity, content, and optional browser zone.
  * @param signal - carrier cancellation, owning the call until inbox acceptance.
  * @param signal - carrier cancellation, owning the call until inbox acceptance.
  * @returns the accepted message's inbox identity.
  * @returns the accepted message's inbox identity.
  * @throws {RemoteError} `gateway/bad-request`, `subagent/attachment-invalid`,
  * @throws {RemoteError} `gateway/bad-request`, `subagent/attachment-invalid`,

+ 11 - 8
docs/subsystems/subagent.zh.md

@@ -141,13 +141,15 @@ persisted Session
 | `waiting` | 唤醒并 steer 同一 Activation |
 | `waiting` | 唤醒并 steer 同一 Activation |
 | 无 Activation | 冷恢复新的 Activation,然后 steer |
 | 无 Activation | 冷恢复新的 Activation,然后 steer |
 
 
-`running` 表示 Agent 拥有活跃的准入或轮次,或正在唤醒收件箱工作;`waiting` 表示它已完全停稳,但仍拥有至少一个尚未完成 dispose 的子 Activation;`settled` 表示已完全停稳且其拥有的每个子级都已 dispose,此时管理器会 dispose [`AgentHandle`](core.zh.md#creation-and-ownership) 并移除该 Activation。管理器根据 Agent 的完全停稳状态与其拥有的子级集合推导这些内部条件,而非维护第二套执行状态机。
+`running` 表示 Agent 拥有活跃的 driver 或 maintenance 任务;`waiting` 表示没有活跃的 Agent 工作,但其 Inbox 非空或仍拥有至少一个尚未完成 dispose 的子 Activation;`settled` 表示没有活跃的 Agent 工作、Inbox 为空且其拥有的每个子级都已 dispose,此时管理器会 dispose [`AgentHandle`](core.zh.md#creation-and-ownership) 并移除该 Activation。管理器根据 `Agent.whenIdle()`、`Agent.inbox.hasPending`、其拥有的子级集合,以及让过期观察失效的 Activation generation 推导这些内部条件,而非维护第二套执行状态机。最终 Session flush 之后,child-lock 决策会通过 `Agent.runMaintenance()` 的同步 task 入口占用 idle 阶段,并在同一个 JavaScript turn 内关闭准入。这条保守规则不区分投递模式:`Agent.inject()` 停放的 context 可以让空闲 Activation 及其在线祖先继续驻留,直到唤醒投递将其 claim、queue 变更将其移除,或 manager teardown 将其丢弃。
 
 
-Agent 收件箱是唯一队列。每条 Agent 消息都使用 `Agent.steer()`:空闲目标会启动一个轮次,运行中目标则在最近的 step 边界领取消息。投递成功会返回被接受的 `MessageId`;既有的 `agent/inbox/inserted`、`agent/inbox/claimed` 与 `agent/inbox/discarded` 事件仍是消息生命周期的观测点,继续执行层不定义任何 subagent 专属的投递路由
+Agent 收件箱是唯一队列。每条 Agent 消息都使用 `Agent.steer()`:空闲目标会启动一个轮次,运行中目标则在最近的 step 边界领取消息。浏览器 `subagent.prompt` Remote 会另行通过同一条内部准入路径携带 `delivery: 'queue' | 'steer'`;Queue 开启后续 FIFO 轮次,Steer 保留 Agent loop 的 best-effort 最近 step 行为以及消息的人类来源。投递成功会返回被接受的 `MessageId`;既有的 `agent/inbox/inserted`、`agent/inbox/claimed` 与 `agent/inbox/discarded` 事件仍是消息生命周期的观测点,继续执行层不定义第二条队列
 
 
 权限来自确切在线 sender。parent 到 child 的投递要求目标的 `SessionHeader.parentSession` 指向 sender;child 到 parent 的投递要求 sender 的驻留 Activation 指向目标。sibling、相隔多于一条边的 ancestor、self-target、陈旧 Agent 对象与一次性 child 都会被拒绝。每条已接受消息都以 `Agent <sender-id> sent a message:` 作为前缀,并记录 `AgentMessageSource`;来源信息记录 sender,但不授予权限。
 权限来自确切在线 sender。parent 到 child 的投递要求目标的 `SessionHeader.parentSession` 指向 sender;child 到 parent 的投递要求 sender 的驻留 Activation 指向目标。sibling、相隔多于一条边的 ancestor、self-target、陈旧 Agent 对象与一次性 child 都会被拒绝。每条已接受消息都以 `Agent <sender-id> sent a message:` 作为前缀,并记录 `AgentMessageSource`;来源信息记录 sender,但不授予权限。
 
 
-对于 `startContinuable()` 与 `sendMessage()`,调用方 signal 仅在收件箱接受之前掌管查找、物化与准入。此后管理器独立掌管该 Activation:之后的调用方取消既不会取消已接受的轮次,也不会 dispose 子 agent。浏览器中的人类提示仍由私有 Queue 适配器处理,因此继续产生独立 FIFO 轮次。
+对于 `startContinuable()`、`sendMessage()` 与浏览器 prompt 投递,调用方 signal 仅在收件箱接受之前掌管查找、物化与准入。此后管理器独立掌管该 Activation:之后的调用方取消既不会取消已接受的轮次,也不会 dispose 子 agent。公开 subagent 服务不暴露由调用方选择的 Agent 消息调度;浏览器人类 Queue 与 Steer 仍是内部适配器选择。
+
+在线 queue occurrence 变更属于 Session 域。只有在线 subagent-owned Agent 的当前 projection identity 为 continuable,且其 descriptor 序号位于该 child 自身的非 seed suffix 时,`session.updateQueue` 才会接纳普通 Edit、Remove 与 QueueDock Steer。Identity projection 以 last-wins 方式折叠 descriptor,因此 child descriptor 会覆盖 fork lineage 保留的 descriptor;own-suffix 序号检查会阻止仅来自 seed 的祖先 identity 授权变更。One-shot、缺失、未知、损坏或冷 child 会被拒绝,queue 变更绝不会冷恢复 child。这些变更以目标 Session id 作为人类权限,包括待处理 `nextStep` steering 或注入 context。Steer 要求 queued `MessageId`,且 command 开始时 Agent 必须报告 running;准入后发生取消时,会使用 Agent 已接受的唤醒 `nextTurn` fallback。Edit 会在同一个 `MessageId` 下改写内容,且 Edit 与 Steer 都会同步完成 Inbox 变更,因此 settlement 只会观察最终状态。`agent/inbox/claimed` 与 `agent/inbox/discarded` 都会唤醒 watcher 重新读取是否仍有待处理 occurrence;这样,直接 Agent 投递可以恢复停放工作,而移除最后一个停放 occurrence 可使 idle child 结算。[人类 inbox 控制 Agent Note](../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.zh.md)拥有这些语义。
 
 
 `SubagentRuntime.interrupt(targetSessionId, authority)` 是唯一的公开停止操作:它同步完成鉴权,对在线目标发出 `Agent.cancel(cause, { keepInbox: true })`,然后不等待完全停稳即返回。Activation、其尚未领取的待处理 inbox 工作与已发布的后代均不受影响;已被领取进入中断轮次的工作不会重新入队。被中断的 driver 进入 idle 后,一次唤醒发送会恢复被暂停的 FIFO 队列。不存在的目标——未知、一次性或已结算——以及未绑定管理器的组合是被接受的 no-op。对在线目标,错误的 parent 地址或不在其在线祖先链中的调用方会以 `UNAUTHORIZED` 拒绝;陈旧的 ancestor 对象和指向自身的 ancestor 请求会在查找目标前拒绝。
 `SubagentRuntime.interrupt(targetSessionId, authority)` 是唯一的公开停止操作:它同步完成鉴权,对在线目标发出 `Agent.cancel(cause, { keepInbox: true })`,然后不等待完全停稳即返回。Activation、其尚未领取的待处理 inbox 工作与已发布的后代均不受影响;已被领取进入中断轮次的工作不会重新入队。被中断的 driver 进入 idle 后,一次唤醒发送会恢复被暂停的 FIFO 队列。不存在的目标——未知、一次性或已结算——以及未绑定管理器的组合是被接受的 no-op。对在线目标,错误的 parent 地址或不在其在线祖先链中的调用方会以 `UNAUTHORIZED` 拒绝;陈旧的 ancestor 对象和指向自身的 ancestor 请求会在查找目标前拒绝。
 
 
@@ -162,7 +164,7 @@ type SubagentInterruptAuthority =
   | { readonly kind: 'ancestor'; readonly agent: Agent }
   | { readonly kind: 'ancestor'; readonly agent: Agent }
 ```
 ```
 
 
-每个 Activation 都拥有自己的 `AgentHandle` 和一个 `ownedChildren: Set<SessionId>`;由于一份会话至多有一个存活 Activation,子会话 id 无需另一个运行时化身引用即可标识存活的子 agent。启动子 agent 或提交源自 parent 的工作,会在子 agent 能够运行之前将其注册到受继续执行管理的父级集合中;只要该集合非空,该父级就无法 settle。顶层或其他非继续执行的 Agent 没有 Activation,处于 waiting 图之外。只有当子 Agent 已完全停稳、该子 agent 的每个子级都已 dispose、best-effort 的最终会话 flush 结算完毕,且子 agent 的 `AgentHandle` 完成 dispose 之后,才会释放子 agent。
+每个 Activation 都拥有自己的 `AgentHandle` 和一个 `ownedChildren: Set<SessionId>`;由于一份会话至多有一个存活 Activation,子会话 id 无需另一个运行时化身引用即可标识存活的子 agent。启动子 agent 或提交源自 parent 的工作,会在子 agent 能够运行之前将其注册到受继续执行管理的父级集合中;只要该集合非空,该父级就无法 settle。顶层或其他非继续执行的 Agent 没有 Activation,处于 waiting 图之外。只有当子 Agent 没有活跃工作、其 Inbox 为空、该子 agent 的每个子级都已 dispose、best-effort 的最终会话 flush 结算完毕,且子 agent 的 `AgentHandle` 完成 dispose 之后,才会释放子 agent。
 
 
 最终结算会等待 `ctx.sessions.flush(session)`,但会忽略其参与布尔值,因为任意 listener 都无法证明某个持久化后端已存储该状态。rejection 会被记录,但不会使 Activation 失败;管理器仍会 dispose 该 handle 并释放所有权,此后持久化的子 agent 状态在后续恢复时可能缺失或陈旧。管理器卸载会调用内部的管理器全局 drain,关闭准入并 dispose 每片在线森林;`drainContinuableDescendants(parents)` 只关闭由 host 确切拥有的在线 Agent 之下的准入,并 dispose 其可继续后代,而无关森林保持在线。两者都会等待各自作用域内已获准的物化过程,自顶向下传播取消,按 child-first 顺序释放 handle,并且即使个别分支失败也会等待所有选中分支。持久化子会话不受该进程内拆卸的影响。
 最终结算会等待 `ctx.sessions.flush(session)`,但会忽略其参与布尔值,因为任意 listener 都无法证明某个持久化后端已存储该状态。rejection 会被记录,但不会使 Activation 失败;管理器仍会 dispose 该 handle 并释放所有权,此后持久化的子 agent 状态在后续恢复时可能缺失或陈旧。管理器卸载会调用内部的管理器全局 drain,关闭准入并 dispose 每片在线森林;`drainContinuableDescendants(parents)` 只关闭由 host 确切拥有的在线 Agent 之下的准入,并 dispose 其可继续后代,而无关森林保持在线。两者都会等待各自作用域内已获准的物化过程,自顶向下传播取消,按 child-first 顺序释放 handle,并且即使个别分支失败也会等待所有选中分支。持久化子会话不受该进程内拆卸的影响。
 
 
@@ -195,7 +197,7 @@ interface ContinuableStart {
 }
 }
 ```
 ```
 
 
-当驻留 Activation 结算时,管理器会向该 child 持久化的直接 parent 投递一条通知,说明该 epoch 如何结束,并携带其最终 assistant 内容。对每个调用方拿到过 id 的 child,这条投递都是无条件的;它发生在会让 parent 被判定为已结算的所有权释放之前,并通过与 Agent 消息相同的唤醒准入记账到达驻留 parent。若 parent 自身所在的谱系已在拆卸中,这条通知会以不唤醒的方式送达,因为唤醒一个静息 Agent 是开启一个轮次,而不是排队等待工作。其来源信息使用一个独立的 kind,因此 transcript(文本记录)绝不会把运行时的记账呈现为 child 自己写下的内容。
+当驻留 Activation 结算时,管理器会向该 child 持久化的直接 parent 投递一条通知,说明该 epoch 如何结束,并携带其最终 assistant 内容。对每个调用方拿到过 id 的 child,这条投递都是无条件的;它发生在会让 parent 被判定为已结算的所有权释放之前,并通过与 Agent 消息相同的唤醒 Agent 投递到达驻留 parent。若 parent 自身所在的谱系已在拆卸中,这条通知会以不唤醒的方式送达,因为唤醒一个 idle Agent 是开启一个轮次,而不是排队等待工作。其来源信息使用一个独立的 kind,因此 transcript(文本记录)绝不会把运行时的记账呈现为 child 自己写下的内容。
 
 
 ```ts type-equiv
 ```ts type-equiv
 /**
 /**
@@ -615,11 +617,12 @@ listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise<Subagen
  * Deliver one browser-authored message to a continuable child through the
  * Deliver one browser-authored message to a continuable child through the
  * exact live direct parent, retaining the caller-minted request identity and
  * exact live direct parent, retaining the caller-minted request identity and
  * validated browser zone on the accepted message. Success identifies the
  * validated browser zone on the accepted message. Success identifies the
- * message the child's FIFO inbox accepted; later execution is independent of
- * this call.
+ * message the child's inbox accepted; later execution is independent of this
+ * call. Queue delivery targets a later turn; steer delivery targets the
+ * nearest step and retains the Agent loop's best-effort fallback semantics.
  * Image parts are admitted and persisted through the attachment store
  * Image parts are admitted and persisted through the attachment store
  * before delivery, and the child's model must accept image input.
  * before delivery, and the child's model must accept image input.
- * @param request - durable address, minted identity, content, and optional browser zone.
+ * @param request - durable address, delivery, minted identity, content, and optional browser zone.
  * @param signal - carrier cancellation, owning the call until inbox acceptance.
  * @param signal - carrier cancellation, owning the call until inbox acceptance.
  * @returns the accepted message's inbox identity.
  * @returns the accepted message's inbox identity.
  * @throws {RemoteError} `gateway/bad-request`, `subagent/attachment-invalid`,
  * @throws {RemoteError} `gateway/bad-request`, `subagent/attachment-invalid`,

+ 2 - 2
packages/api/session-controller/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/api/session-controller/README.md
 #   pnpm run verify-translation-pairing --write packages/api/session-controller/README.md
-README.md: ec8b70b1724afc462d2d0b5d267603bcab4aceb6
-README.zh.md: 6fc0c92300262bfdf0fc52deaec1d66313d91ebe
+README.md: a2639ebd5fb648ed193d7c3575a649891ebf76b9
+README.zh.md: 7bed20d3e47855b045bb838b354328f1769273e2

文件差异内容过多而无法显示
+ 1 - 1
packages/api/session-controller/README.md


文件差异内容过多而无法显示
+ 1 - 1
packages/api/session-controller/README.zh.md


+ 2 - 0
packages/api/session-controller/package.json

@@ -99,6 +99,7 @@
     "@deepseek-ai/dsh-typert-protocol": "workspace:^",
     "@deepseek-ai/dsh-typert-protocol": "workspace:^",
     "@deepseek-ai/dsh-typert-registry": "workspace:^",
     "@deepseek-ai/dsh-typert-registry": "workspace:^",
     "@deepseek-ai/dsh-util-time": "workspace:^",
     "@deepseek-ai/dsh-util-time": "workspace:^",
+    "@deepseek-ai/dsh-util-values": "workspace:^",
     "@deepseek-ai/dsh-util-workspace-path": "workspace:^",
     "@deepseek-ai/dsh-util-workspace-path": "workspace:^",
     "@deepseek-ai/dsh-workspace": "workspace:^"
     "@deepseek-ai/dsh-workspace": "workspace:^"
   },
   },
@@ -145,6 +146,7 @@
     "@deepseek-ai/dsh-typert-registry": "workspace:^",
     "@deepseek-ai/dsh-typert-registry": "workspace:^",
     "@deepseek-ai/dsh-util-crypto": "workspace:^",
     "@deepseek-ai/dsh-util-crypto": "workspace:^",
     "@deepseek-ai/dsh-util-time": "workspace:^",
     "@deepseek-ai/dsh-util-time": "workspace:^",
+    "@deepseek-ai/dsh-util-values": "workspace:^",
     "@deepseek-ai/dsh-util-workspace-path": "workspace:^",
     "@deepseek-ai/dsh-util-workspace-path": "workspace:^",
     "@deepseek-ai/dsh-workspace": "workspace:^"
     "@deepseek-ai/dsh-workspace": "workspace:^"
   }
   }

+ 1 - 1
packages/api/session-controller/src/client/contract/session.ts

@@ -98,7 +98,7 @@ export interface ISession {
     attachmentId: AttachmentIdType,
     attachmentId: AttachmentIdType,
   ): Promise<RemoteResult<{ attachment: ImageAttachmentRef; data: Uint8Array }>>
   ): Promise<RemoteResult<{ attachment: ImageAttachmentRef; data: Uint8Array }>>
   /**
   /**
-   * Apply one edit, remove, or strict steer action to a still-pending queue occurrence.
+   * Apply one edit, remove, or Steer action to a still-pending queue occurrence.
    * @param itemId - agent-owned inbox occurrence identity.
    * @param itemId - agent-owned inbox occurrence identity.
    * @param action - requested queue operation.
    * @param action - requested queue operation.
    * @returns acceptance, or a business/transport error.
    * @returns acceptance, or a business/transport error.

+ 1 - 0
packages/api/session-controller/src/client/sessions/session.ts

@@ -271,6 +271,7 @@ export class Session implements SessionFace {
         parentSessionId: this.address.parentSessionId,
         parentSessionId: this.address.parentSessionId,
         childSessionId: this.address.childSessionId,
         childSessionId: this.address.childSessionId,
         mode: 'continuable',
         mode: 'continuable',
+        delivery: mode,
         content: routedContent,
         content: routedContent,
         clientTimeZone: resolvedClientTimeZone(),
         clientTimeZone: resolvedClientTimeZone(),
       }, signal)
       }, signal)

+ 27 - 12
packages/api/session-controller/src/commands.ts

@@ -19,6 +19,7 @@ import type { SessionEvent, SessionHeader, SessionId, UserMessage } from '@deeps
 import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
 import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
 import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title'
 import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title'
 import { canonicalClientTimeZone } from '@deepseek-ai/dsh-util-time'
 import { canonicalClientTimeZone } from '@deepseek-ai/dsh-util-time'
+import { assertNever } from '@deepseek-ai/dsh-util-values'
 import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
 import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
 import type { Workspace } from '@deepseek-ai/dsh-workspace'
 import type { Workspace } from '@deepseek-ai/dsh-workspace'
 import {
 import {
@@ -415,12 +416,18 @@ export class SessionCommandController {
       )
       )
     }
     }
     const agent = this.ctx.agents.get(request.sessionId)
     const agent = this.ctx.agents.get(request.sessionId)
-    if (agent !== undefined && hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) {
-      throw apiSessionSubagentOwnershipError(request.sessionId)
-    }
     if (agent === undefined) {
     if (agent === undefined) {
       throw new RemoteError('session/queue-item-not-found', 'queued item is no longer pending', { itemId: request.itemId })
       throw new RemoteError('session/queue-item-not-found', 'queued item is no longer pending', { itemId: request.itemId })
     }
     }
+    if (hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) {
+      const identity = this.ctx.sessionProjections
+        .snapshot(agent.session, ['subagent'])
+        .values.subagent
+      if (identity?.mode !== 'continuable'
+        || !agent.session.isOwnSeq(identity.seq)) {
+        throw apiSessionSubagentOwnershipError(request.sessionId)
+      }
+    }
     const nextTurn = agent.inbox.nextTurn.find(message => message.id === request.itemId)
     const nextTurn = agent.inbox.nextTurn.find(message => message.id === request.itemId)
     const nextStep = agent.inbox.nextStep.find(message => message.id === request.itemId)
     const nextStep = agent.inbox.nextStep.find(message => message.id === request.itemId)
     const located = nextTurn === undefined
     const located = nextTurn === undefined
@@ -433,20 +440,28 @@ export class SessionCommandController {
     if (request.action.kind === 'steer' && (target !== 'next-turn' || agent.status !== 'running')) {
     if (request.action.kind === 'steer' && (target !== 'next-turn' || agent.status !== 'running')) {
       throw new RemoteError('session/steer-unavailable', 'current turn no longer accepts steering', { itemId: request.itemId })
       throw new RemoteError('session/steer-unavailable', 'current turn no longer accepts steering', { itemId: request.itemId })
     }
     }
-    if (request.action.kind === 'edit') {
-      agent.inbox.replace(request.itemId, freezeMessage<UserMessage>({
-        ...message,
-        content: [...request.action.content],
-      }))
-    } else {
-      agent.inbox.remove(request.itemId)
-      if (request.action.kind === 'remove') {
+    switch (request.action.kind) {
+      case 'edit':
+        agent.inbox.replace(request.itemId, freezeMessage<UserMessage>({
+          ...message,
+          content: [...request.action.content],
+        }))
+        break
+      case 'remove': {
+        agent.inbox.remove(request.itemId)
         const source = message.source
         const source = message.source
         if (source.kind === 'user' && 'rpcId' in source) {
         if (source.kind === 'user' && 'rpcId' in source) {
           this.ctx.fileUploads.retirePrompt(agent, source.rpcId)
           this.ctx.fileUploads.retirePrompt(agent, source.rpcId)
         }
         }
+        break
       }
       }
-      if (request.action.kind === 'steer') agent.steer(message)
+      case 'steer':
+        agent.inbox.remove(request.itemId)
+        agent.steer(message)
+        break
+      /* v8 ignore next 2 -- closed-union exhaustiveness guard */
+      default:
+        assertNever(request.action, 'queue action')
     }
     }
     return { accepted: true }
     return { accepted: true }
   }
   }

+ 136 - 6
packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts

@@ -4,14 +4,20 @@ import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
 import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment'
 import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment'
 import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import { createAssistantMessage, createUserMessage, MessageId } from '@deepseek-ai/dsh-llm'
 import { createAssistantMessage, createUserMessage, MessageId } from '@deepseek-ai/dsh-llm'
-import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session'
-import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
+import SessionStore, {
+  SESSION_FORMAT_VERSION, Session, SessionId, SessionLogOffset, SessionSeq,
+} from '@deepseek-ai/dsh-session'
+import type { SessionEvent, SessionHeader, UserMessage } from '@deepseek-ai/dsh-session'
+import { snapshotSubagentDescriptor, SUBAGENT_DESCRIPTOR_VERSION } from '@deepseek-ai/dsh-subagent'
+import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts'
 import { describe, expect, it, vi } from 'vitest'
 import { describe, expect, it, vi } from 'vitest'
 import { ApiSessionAgentController } from '../src/agent.ts'
 import { ApiSessionAgentController } from '../src/agent.ts'
 import { SessionCommandController } from '../src/commands.ts'
 import { SessionCommandController } from '../src/commands.ts'
 import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts'
 import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts'
 
 
-async function commandHarness(): Promise<{
+async function commandHarness(
+  childMode?: 'continuable' | 'seeded-continuable' | 'seed-only' | 'one-shot' | 'unknown' | 'corrupt',
+): Promise<{
   ctx: Context
   ctx: Context
   controller: SessionCommandController
   controller: SessionCommandController
   agent: Agent
   agent: Agent
@@ -22,9 +28,48 @@ async function commandHarness(): Promise<{
   const ctx = new Context()
   const ctx = new Context()
   await ctx.plugin(SessionStore)
   await ctx.plugin(SessionStore)
   await ctx.plugin(AgentRegistry)
   await ctx.plugin(AgentRegistry)
-  const session = ctx.sessions.create(SessionId('commands-session'), { meta: { cwd: '/workspace' } })
+  installSessionReadTestServices(ctx)
+  ctx.sessionProjections.register(subagentIdentityProjectionDefinition)
+  const sessionId = SessionId('commands-session')
+  const ancestor = Session.create(SessionId('ancestor'))
+  ancestor.append('subagent/descriptor', snapshotSubagentDescriptor({
+    mode: 'continuable', provider: 'test', label: 'ancestor',
+  }))
+  // A seeded child inherits exactly the ancestor prefix; its own descriptor
+  // is appended after creation, as the continuation manager does. `seed-only`
+  // never appends one: the identity folds as continuable, but from the
+  // inherited prefix rather than this Session's own suffix.
+  const lineage = childMode === 'seeded-continuable' || childMode === 'seed-only'
+    ? ancestor.snapshotEvents()
+    : undefined
+  const session = ctx.sessions.create(sessionId, {
+    ...lineage === undefined ? {} : { seed: lineage, inheritedEventCount: SessionLogOffset(lineage.length) },
+    meta: {
+      cwd: '/workspace',
+      ...(childMode === undefined ? {} : {
+        origin: 'subagent' as const,
+        parentSession: SessionId('offline-parent'),
+      }),
+      ...lineage === undefined ? {} : { isSeeded: true },
+    },
+  })
+  if (childMode === 'continuable' || childMode === 'seeded-continuable') {
+    session.append('subagent/descriptor', snapshotSubagentDescriptor({
+      mode: 'continuable', provider: 'test', label: 'child',
+    }))
+  } else if (childMode === 'one-shot') {
+    session.append('subagent/descriptor', snapshotSubagentDescriptor({
+      mode: 'one-shot', provider: 'test', label: 'child',
+    }))
+  } else if (childMode === 'corrupt') {
+    session.append('subagent/descriptor', {
+      version: SUBAGENT_DESCRIPTOR_VERSION,
+      mode: 'continuable',
+      provider: 1,
+    } as never)
+  }
   const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
   const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
-  const steer = vi.fn()
+  const steer = vi.fn((message: UserMessage) => { inbox.append('next-step', message) })
   const cancel = vi.fn()
   const cancel = vi.fn()
   const agent = {
   const agent = {
     id: session.id,
     id: session.id,
@@ -52,7 +97,14 @@ async function commandHarness(): Promise<{
     serializeImageAdmission: <Value>(_agent: Agent, operation: () => Promise<Value>) => operation(),
     serializeImageAdmission: <Value>(_agent: Agent, operation: () => Promise<Value>) => operation(),
     composeAgent: () => Promise.resolve({ setup: () => {} }),
     composeAgent: () => Promise.resolve({ setup: () => {} }),
   } as unknown as ApiSessionAgentController
   } as unknown as ApiSessionAgentController
-  return { ctx, controller: new SessionCommandController(ctx, agents, '/workspace'), agent, inbox, steer, cancel }
+  return {
+    ctx,
+    controller: new SessionCommandController(ctx, agents, '/workspace'),
+    agent,
+    inbox,
+    steer,
+    cancel,
+  }
 }
 }
 
 
 async function expectFailure(operation: Promise<unknown>, code: string): Promise<void> {
 async function expectFailure(operation: Promise<unknown>, code: string): Promise<void> {
@@ -100,6 +152,9 @@ describe('Session queue commands', () => {
       action: { kind: 'edit', content: [{ type: 'text', text: 'edited' }] },
       action: { kind: 'edit', content: [{ type: 'text', text: 'edited' }] },
     })).toEqual({ accepted: true })
     })).toEqual({ accepted: true })
     expect(inbox.nextTurn[0]?.content).toEqual([{ type: 'text', text: 'edited' }])
     expect(inbox.nextTurn[0]?.content).toEqual([{ type: 'text', text: 'edited' }])
+    // An edit rewrites content in place, so the occurrence a client addressed
+    // by id stays addressable.
+    expect(inbox.nextTurn[0]?.id).toBe(queued.id)
     expect(controller.updateQueue({
     expect(controller.updateQueue({
       sessionId: agent.id, itemId: nextStep.id, action: { kind: 'remove' },
       sessionId: agent.id, itemId: nextStep.id, action: { kind: 'remove' },
     })).toEqual({ accepted: true })
     })).toEqual({ accepted: true })
@@ -136,6 +191,81 @@ describe('Session queue commands', () => {
     expect(cancel).toHaveBeenCalledWith({ kind: 'user' }, { keepInbox: true })
     expect(cancel).toHaveBeenCalledWith({ kind: 'user' }, { keepInbox: true })
     await ctx.fiber.dispose()
     await ctx.fiber.dispose()
   })
   })
+
+  it.each(['continuable', 'seeded-continuable'] as const)(
+    'mutates both inbox destinations of a live %s child while its parent is offline',
+    async (childMode) => {
+      const { ctx, controller, agent, inbox, steer } = await commandHarness(childMode)
+      const queued = createUserMessage({
+        content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' },
+      })
+      const context = createUserMessage({
+        content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'test' },
+      })
+      inbox.append('next-turn', queued)
+      inbox.append('next-step', context)
+
+      expect(controller.updateQueue({
+        sessionId: agent.id,
+        itemId: context.id,
+        action: { kind: 'edit', content: [{ type: 'text', text: 'edited context' }] },
+      })).toEqual({ accepted: true })
+      const editedContext = inbox.nextStep[0]
+      expect(editedContext).toMatchObject({
+        content: [{ type: 'text', text: 'edited context' }],
+        source: context.source,
+      })
+      expect(editedContext?.id).toBe(context.id)
+      if (editedContext === undefined) throw new Error('missing edited context')
+      expect(controller.updateQueue({
+        sessionId: agent.id, itemId: editedContext.id, action: { kind: 'remove' },
+      })).toEqual({ accepted: true })
+      expect(controller.updateQueue({
+        sessionId: agent.id, itemId: queued.id, action: { kind: 'steer' },
+      })).toEqual({ accepted: true })
+      expect(steer).toHaveBeenCalledWith(queued)
+      await ctx.fiber.dispose()
+    },
+  )
+
+  it('removes the selected message before handing it to Agent steering', async () => {
+    const { ctx, controller, agent, inbox, steer } = await commandHarness('continuable')
+    const first = createUserMessage({
+      content: [{ type: 'text', text: 'first' }], source: { kind: 'user' },
+    })
+    const second = createUserMessage({
+      content: [{ type: 'text', text: 'second' }], source: { kind: 'user' },
+    })
+    inbox.append('next-turn', first)
+    inbox.append('next-turn', second)
+    // Stand in for the Agent's cancellation-convergence destination; the
+    // command must accept whichever boundary `Agent.steer()` selects.
+    steer.mockImplementation((message: UserMessage) => { inbox.append('next-turn', message) })
+
+    expect(controller.updateQueue({
+      sessionId: agent.id, itemId: first.id, action: { kind: 'steer' },
+    })).toEqual({ accepted: true })
+    expect(steer).toHaveBeenCalledWith(first)
+    // Ordering proves the removal happened before delivery rather than after.
+    expect(inbox.nextTurn).toEqual([second, first])
+    expect(inbox.nextStep).toEqual([])
+    await ctx.fiber.dispose()
+  })
+
+  it('keeps one-shot, seed-only, missing, and malformed child descriptors behind the ownership fence', async () => {
+    for (const mode of ['one-shot', 'seed-only', 'unknown', 'corrupt'] as const) {
+      const { ctx, controller, agent, inbox } = await commandHarness(mode)
+      const queued = createUserMessage({
+        content: [{ type: 'text', text: mode }], source: { kind: 'user' },
+      })
+      inbox.append('next-turn', queued)
+      await expectFailure(Promise.resolve().then(() => controller.updateQueue({
+        sessionId: agent.id, itemId: queued.id, action: { kind: 'remove' },
+      })), 'session/agent-busy')
+      expect(inbox.nextTurn).toEqual([queued])
+      await ctx.fiber.dispose()
+    }
+  })
 })
 })
 
 
 function imageRef(id: string): ImageAttachmentRef {
 function imageRef(id: string): ImageAttachmentRef {

+ 1 - 0
packages/api/session-controller/tests/manager.client.spec.ts

@@ -320,6 +320,7 @@ describe('subagent catalogs', () => {
         requestId: expect.any(String) as unknown as string,
         requestId: expect.any(String) as unknown as string,
         parentSessionId: S1, childSessionId: S2,
         parentSessionId: S1, childSessionId: S2,
         mode: 'continuable',
         mode: 'continuable',
+        delivery: 'queue',
         content: [{ type: 'text', text: 'continue' }],
         content: [{ type: 'text', text: 'continue' }],
         clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone,
         clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone,
       },
       },

+ 12 - 0
packages/api/session-controller/tests/session.client.spec.ts

@@ -400,9 +400,11 @@ describe('prompt and cancel errors', () => {
     })
     })
     await session.open()
     await session.open()
     const prompted = await session.prompt([{ type: 'text', text: '继续' }], 'queue')
     const prompted = await session.prompt([{ type: 'text', text: '继续' }], 'queue')
+    const steered = await session.prompt([{ type: 'text', text: '现在处理' }], 'steer')
     const cancelled = await session.cancel()
     const cancelled = await session.cancel()
 
 
     expect(prompted).toEqual({ ok: true, value: { accepted: true } })
     expect(prompted).toEqual({ ok: true, value: { accepted: true } })
+    expect(steered).toEqual({ ok: true, value: { accepted: true } })
     expect(cancelled).toEqual({ ok: true, value: { accepted: true } })
     expect(cancelled).toEqual({ ok: true, value: { accepted: true } })
     expect(api.callsOf('session.follow')).toEqual([
     expect(api.callsOf('session.follow')).toEqual([
       {
       {
@@ -419,9 +421,18 @@ describe('prompt and cancel errors', () => {
         requestId: expect.any(String) as unknown as string,
         requestId: expect.any(String) as unknown as string,
         parentSessionId: PARENT, childSessionId: SID,
         parentSessionId: PARENT, childSessionId: SID,
         mode: 'continuable',
         mode: 'continuable',
+        delivery: 'queue',
         content: [{ type: 'text', text: '继续' }],
         content: [{ type: 'text', text: '继续' }],
         clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone,
         clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone,
       },
       },
+      {
+        requestId: expect.any(String) as unknown as string,
+        parentSessionId: PARENT, childSessionId: SID,
+        mode: 'continuable',
+        delivery: 'steer',
+        content: [{ type: 'text', text: '现在处理' }],
+        clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone,
+      },
     ])
     ])
     expect(api.callsOf('subagents.interruptByParent')).toEqual([
     expect(api.callsOf('subagents.interruptByParent')).toEqual([
       { childSessionId: SID, parentSessionId: PARENT, mode: 'continuable' },
       { childSessionId: SID, parentSessionId: PARENT, mode: 'continuable' },
@@ -456,6 +467,7 @@ describe('prompt and cancel errors', () => {
         requestId: expect.any(String) as unknown as string,
         requestId: expect.any(String) as unknown as string,
         parentSessionId: PARENT, childSessionId: SID,
         parentSessionId: PARENT, childSessionId: SID,
         mode: 'continuable',
         mode: 'continuable',
+        delivery: 'queue',
         content,
         content,
         clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone,
         clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone,
       },
       },

+ 1 - 0
packages/api/session-controller/tsconfig.host.json

@@ -44,6 +44,7 @@
     { "path": "../../skill/skill" },
     { "path": "../../skill/skill" },
     { "path": "../../subagent/subagent" },
     { "path": "../../subagent/subagent" },
     { "path": "../../util/time" },
     { "path": "../../util/time" },
+    { "path": "../../util/values" },
     { "path": "../../typert/protocol" },
     { "path": "../../typert/protocol" },
     { "path": "../../typert/registry" },
     { "path": "../../typert/registry" },
     { "path": "../../workspace/workspace" }
     { "path": "../../workspace/workspace" }

+ 2 - 2
packages/client/ui-conversation/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
 #   pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
-README.md: b615730842fbbed0ab63e7e51e0361e3077785d0
-README.zh.md: 36faa9a0f466f8d11d2dd350a51691cf7e8de931
+README.md: 9a1a12ed86afe4d5ccea63045aeade5860f309cc
+README.zh.md: 372f24039a3c880520c2ad690a996754b2ed2e0b

+ 1 - 2
packages/client/ui-conversation/README.md

@@ -48,8 +48,7 @@ Default sends commit optimistically: Enter clears the draft, occurrence table, a
 
 
 Queued submission echoes show “Sending…” beside disabled edit, remove, and steer buttons; a collapsed dock keeps the sending status in its header. A matching Host queue row replaces the echo and enables each action according to its normal text-content and running-state requirements. Prompt acknowledgement alone does not enable queue actions. A failed submission removes its echo and displays an error; the composer restores the failed draft when it is empty or still contains the previous automatic restoration, preserving subsequently typed text.
 Queued submission echoes show “Sending…” beside disabled edit, remove, and steer buttons; a collapsed dock keeps the sending status in its header. A matching Host queue row replaces the echo and enables each action according to its normal text-content and running-state requirements. Prompt acknowledgement alone does not enable queue actions. A failed submission removes its echo and displays an error; the composer restores the failed draft when it is empty or still contains the previous automatic restoration, preserving subsequently typed text.
 
 
-While a normal composer is running, its primary pointer action remains Stop when the draft is empty or input is unavailable. Actionable text or attachments switch the same seat to Queue Send; clearing or successfully submitting the draft restores Stop. The busy-Enter setting continues to select the Queue or Steer keyboard action. Plan mode and active goals do not change attachment intake. Continuable subagents keep separate Send and Stop actions but expose no paperclip, paste, or drop intake.
-
+While a normal composer is running, its primary pointer action remains Stop when the draft is empty or input is unavailable. Actionable text or attachments switch the same seat to Queue Send; clearing or successfully submitting the draft restores Stop. The busy-Enter setting selects the Queue or Steer keyboard action for ordinary Sessions and continuable children. Their QueueDock rows share Edit, Remove, and Steer, and an empty draft shares the steer-all chord. One-shot children remain read-only. Plan mode and active goals do not change attachment intake. Continuable children keep separate Send and Stop actions but expose no paperclip, paste, or drop intake; if their parent is offline, Send and the composer gestures lock while QueueDock controls for the live inbox remain available ([decisions](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md), [inbox controls](../../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.md)).
 
 
 <a id="temporary-composer-entries"></a>
 <a id="temporary-composer-entries"></a>
 ## Temporary composer entries
 ## Temporary composer entries

+ 1 - 2
packages/client/ui-conversation/README.zh.md

@@ -48,8 +48,7 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染
 
 
 排队提交的本地回显在禁用的编辑、删除、插话按钮旁显示“发送中…”;折叠后的队列在标题栏保留发送状态。匹配的 Host 队列行替换回显后,各操作按原有的纯文本内容和运行状态要求启用。仅收到 prompt 确认不会启用队列操作。提交失败会移除回显并显示错误;输入框为空或仍保留上一次自动恢复的内容时,composer 恢复失败草稿,保留用户随后输入的文字。
 排队提交的本地回显在禁用的编辑、删除、插话按钮旁显示“发送中…”;折叠后的队列在标题栏保留发送状态。匹配的 Host 队列行替换回显后,各操作按原有的纯文本内容和运行状态要求启用。仅收到 prompt 确认不会启用队列操作。提交失败会移除回显并显示错误;输入框为空或仍保留上一次自动恢复的内容时,composer 恢复失败草稿,保留用户随后输入的文字。
 
 
-普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Queue Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置继续选择 Queue 或 Steer 键盘操作。Plan Mode 与 active goal 不改变附件入口。continuable 子代理保留独立的 Send 与 Stop 操作,但不提供回形针、粘贴或拖放入口。
-
+普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Queue Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置会为普通 Session 与可继续 child 选择 Queue 或 Steer 键盘操作。它们的 QueueDock 行共享 Edit、Remove 与 Steer,空草稿也共享 steer-all 组合键。One-shot child 继续只读。Plan Mode 与 active goal 不改变附件入口。可继续 child 保留独立的 Send 与 Stop 操作,但不提供回形针、粘贴或拖放入口;parent 离线时,Send 与 composer 手势锁定,但在线 inbox 的 QueueDock 控制仍可使用([决策](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md)、[inbox 控制](../../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.zh.md))。
 
 
 <a id="temporary-composer-entries"></a>
 <a id="temporary-composer-entries"></a>
 ## 临时 composer entry
 ## 临时 composer entry

+ 4 - 4
packages/client/ui-conversation/src/client/input/hub.ts

@@ -187,14 +187,14 @@ export class InputHub implements SessionInputResolver {
   }
   }
 
 
   /**
   /**
-   * Steer every still-pending queued message into the running turn, in FIFO
-   * order — the same strict-steer operation as the queue dock's per-row
-   * button. A turn closing mid-way (`session/steer-unavailable`) or a row already
+   * Submit every still-pending queued message through QueueDock Steer, in FIFO
+   * request order — the same operation as the queue dock's per-row button.
+   * An Agent stopping before a command (`session/steer-unavailable`) or a row already
    * claimed by the agent (`session/queue-item-not-found`) converges silently, while a
    * claimed by the agent (`session/queue-item-not-found`) converges silently, while a
    * genuine failure surfaces as one composer notice. Repeated triggers
    * genuine failure surfaces as one composer notice. Repeated triggers
    * (e.g. two rapid empty-draft chords) rely on that `session/queue-item-not-found`
    * (e.g. two rapid empty-draft chords) rely on that `session/queue-item-not-found`
    * convergence: the snapshot may still list a row the host already steered,
    * convergence: the snapshot may still list a row the host already steered,
-   * and the duplicate strict steer is a silent no-op.
+   * and the duplicate Steer is a silent no-op.
    * @param session - the addressed host session.
    * @param session - the addressed host session.
    * @param shell - the resident shell (notice outlet).
    * @param shell - the resident shell (notice outlet).
    */
    */

+ 1 - 1
packages/client/ui-conversation/src/client/queue/QueueDock.tsx

@@ -99,7 +99,7 @@ export function QueueDock({ useSession, updateQueue, notify, loadImage, t }: Que
   }, [pendingSubmissions, queue])
   }, [pendingSubmissions, queue])
   const rowCount = queue.length + pendingQueue.length
   const rowCount = queue.length + pendingQueue.length
   const running = useSession(s => s.running)
   const running = useSession(s => s.running)
-  const queueMutable = useSession(s => s.subagent === null)
+  const queueMutable = useSession(s => s.subagent === null || s.subagent.address.mode === 'continuable')
   const [editing, setEditing] = useState<{ id: QueueItemId; text: string } | null>(null)
   const [editing, setEditing] = useState<{ id: QueueItemId; text: string } | null>(null)
   const [busy, setBusy] = useState<QueueItemId | null>(null)
   const [busy, setBusy] = useState<QueueItemId | null>(null)
   const [collapsed, setCollapsed] = useState(true)
   const [collapsed, setCollapsed] = useState(true)

+ 2 - 2
packages/client/ui-conversation/src/client/service.ts

@@ -51,10 +51,10 @@ export interface IConversation {
    */
    */
   send(text: string): Promise<void>
   send(text: string): Promise<void>
   /**
   /**
-   * Apply one edit, remove, or strict steer operation to a pending queue occurrence.
+   * Apply one edit, remove, or Steer operation to a pending queue occurrence.
    * @param itemId - agent-owned inbox occurrence identity.
    * @param itemId - agent-owned inbox occurrence identity.
    * @param action - requested queue operation.
    * @param action - requested queue operation.
-   * @returns completion; converged strict-steer races resolve, while other failures reject.
+   * @returns completion; converged QueueDock races resolve, while other failures reject.
    */
    */
   updateQueue(itemId: QueueItemId, action: QueueAction): Promise<void>
   updateQueue(itemId: QueueItemId, action: QueueAction): Promise<void>
   /**
   /**

+ 5 - 4
packages/client/ui-conversation/src/client/skeleton/InputBar.tsx

@@ -138,7 +138,8 @@ export const InputBar = memo(function InputBar({
   const workspaceTrigger = inert && !removed && onRequestWorkspace !== undefined
   const workspaceTrigger = inert && !removed && onRequestWorkspace !== undefined
   const editorDisabled = removed || (locked && !workspaceTrigger)
   const editorDisabled = removed || (locked && !workspaceTrigger)
   const editable = live && !locked && !machineBusy
   const editable = live && !locked && !machineBusy
-  const canSteerQueue = !locked && !machineBusy && !commandMenuOpen && empty && running && subagent === null
+  const steeringAvailable = subagent === null || subagent.address.mode === 'continuable'
+  const canSteerQueue = !locked && !machineBusy && !commandMenuOpen && empty && running && steeringAvailable
     && input.queue.some(row => row.placement === 'queued')
     && input.queue.some(row => row.placement === 'queued')
 
 
   useEffect(() => {
   useEffect(() => {
@@ -262,11 +263,11 @@ export const InputBar = memo(function InputBar({
   // The keymap handlers read live bar state through this ref so the editor
   // The keymap handlers read live bar state through this ref so the editor
   // registration survives re-renders without re-arming per keystroke.
   // registration survives re-renders without re-arming per keystroke.
   const gate = useRef({
   const gate = useRef({
-    locked, machineBusy, canSteerQueue, running, subagent, resolveSubmitMode,
+    locked, machineBusy, canSteerQueue, running, steeringAvailable, resolveSubmitMode,
     intakeFiles, uploadsPending, showToast, t,
     intakeFiles, uploadsPending, showToast, t,
   })
   })
   gate.current = {
   gate.current = {
-    locked, machineBusy, canSteerQueue, running, subagent, resolveSubmitMode,
+    locked, machineBusy, canSteerQueue, running, steeringAvailable, resolveSubmitMode,
     intakeFiles, uploadsPending, showToast, t,
     intakeFiles, uploadsPending, showToast, t,
   }
   }
 
 
@@ -296,7 +297,7 @@ export const InputBar = memo(function InputBar({
         keyboard.submit(g.resolveSubmitMode(
         keyboard.submit(g.resolveSubmitMode(
           g.running,
           g.running,
           accelerated ? 'accelerated' : 'enter',
           accelerated ? 'accelerated' : 'enter',
-          g.subagent === null,
+          g.steeringAvailable,
         ))
         ))
       },
       },
       intakeFiles: (files) => { gate.current.intakeFiles(files) },
       intakeFiles: (files) => { gate.current.intakeFiles(files) },

+ 19 - 14
packages/client/ui-conversation/tests/input-bar.client.spec.tsx

@@ -462,12 +462,6 @@ describe('Enter semantics', () => {
   it('advertises the empty-draft whole-queue steering gesture when it is available', () => {
   it('advertises the empty-draft whole-queue steering gesture when it is available', () => {
     const { placeholder } = bench({ running: true, queue: [row('q-1')], steerQueue: vi.fn() })
     const { placeholder } = bench({ running: true, queue: [row('q-1')], steerQueue: vi.fn() })
     expect(placeholder).toBe('Cmd/Ctrl+Enter 插话发送全部排队消息')
     expect(placeholder).toBe('Cmd/Ctrl+Enter 插话发送全部排队消息')
-  })
-
-  it('keeps the owning placeholder or ordinary guidance when whole-queue steering is unavailable', () => {
-    expect(bench({ running: true }).placeholder).toBe('发消息或做任务… / 调用指令 @ 文件或对话')
-    expect(bench({ queue: [row('q-1')] }).placeholder).toBe('发消息或做任务… / 调用指令 @ 文件或对话')
-    expect(bench({ running: true, queue: [row('q-1')], draft: '消息' }).placeholder).toBe('发消息或做任务… / 调用指令 @ 文件或对话')
     expect(bench({
     expect(bench({
       running: true,
       running: true,
       queue: [row('q-1')],
       queue: [row('q-1')],
@@ -475,7 +469,13 @@ describe('Enter semantics', () => {
         address: { parentSessionId: 'parent' as SessionId, childSessionId: SID, mode: 'continuable' },
         address: { parentSessionId: 'parent' as SessionId, childSessionId: SID, mode: 'continuable' },
         parentAvailable: true,
         parentAvailable: true,
       },
       },
-    }).placeholder).toBe('发消息或做任务… / 调用指令 @ 文件或对话')
+    }).placeholder).toBe('Cmd/Ctrl+Enter 插话发送全部排队消息')
+  })
+
+  it('keeps the owning placeholder or ordinary guidance when whole-queue steering is unavailable', () => {
+    expect(bench({ running: true }).placeholder).toBe('发消息或做任务… / 调用指令 @ 文件或对话')
+    expect(bench({ queue: [row('q-1')] }).placeholder).toBe('发消息或做任务… / 调用指令 @ 文件或对话')
+    expect(bench({ running: true, queue: [row('q-1')], draft: '消息' }).placeholder).toBe('发消息或做任务… / 调用指令 @ 文件或对话')
     expect(bench({
     expect(bench({
       running: true,
       running: true,
       queue: [row('q-1')],
       queue: [row('q-1')],
@@ -566,7 +566,7 @@ describe('Enter semantics', () => {
     expect(ctrl.sink).not.toHaveBeenCalled()
     expect(ctrl.sink).not.toHaveBeenCalled()
   })
   })
 
 
-  it('queue steering stays gated: idle, subagent, plain Enter, empty queue, or steering-only rows', () => {
+  it('queue steering stays gated by activity, gesture, capability, and queued rows', () => {
     // Idle: the gesture falls through to the machine's empty-draft no-op.
     // Idle: the gesture falls through to the machine's empty-draft no-op.
     const idle = bench({ queue: [row('q-1')], steerQueue: vi.fn() })
     const idle = bench({ queue: [row('q-1')], steerQueue: vi.fn() })
     fireEvent.keyDown(idle.textarea, { key: 'Enter', metaKey: true })
     fireEvent.keyDown(idle.textarea, { key: 'Enter', metaKey: true })
@@ -579,7 +579,7 @@ describe('Enter semantics', () => {
     expect(plain.steerQueue).not.toHaveBeenCalled()
     expect(plain.steerQueue).not.toHaveBeenCalled()
     expect(plain.sink).not.toHaveBeenCalled()
     expect(plain.sink).not.toHaveBeenCalled()
 
 
-    // Subagent sessions keep the queue transport (no steering face).
+    // Continuable children expose the same steering face as ordinary Sessions.
     const subagent = {
     const subagent = {
       address: {
       address: {
         parentSessionId: 'parent' as SessionId,
         parentSessionId: 'parent' as SessionId,
@@ -588,9 +588,10 @@ describe('Enter semantics', () => {
       },
       },
       parentAvailable: true,
       parentAvailable: true,
     }
     }
-    const child = bench({ running: true, subagent, queue: [row('q-1')], steerQueue: vi.fn() })
+    const childSteerQueue = vi.fn()
+    const child = bench({ running: true, subagent, queue: [row('q-1')], steerQueue: childSteerQueue })
     fireEvent.keyDown(child.textarea, { key: 'Enter', metaKey: true })
     fireEvent.keyDown(child.textarea, { key: 'Enter', metaKey: true })
-    expect(child.steerQueue).not.toHaveBeenCalled()
+    expect(childSteerQueue).toHaveBeenCalledTimes(1)
     expect(child.sink).not.toHaveBeenCalled()
     expect(child.sink).not.toHaveBeenCalled()
 
 
     // No queued rows: the empty draft stays a no-op.
     // No queued rows: the empty draft stays a no-op.
@@ -859,7 +860,7 @@ describe('running and lock semantics', () => {
     expect(stop).not.toHaveBeenCalled()
     expect(stop).not.toHaveBeenCalled()
   })
   })
 
 
-  it('keeps both running subagent Enter gestures on Queue transport', () => {
+  it('applies the ordinary Queue/Steer preference to a running continuable child', () => {
     const subagent = {
     const subagent = {
       address: {
       address: {
         parentSessionId: 'parent' as SessionId,
         parentSessionId: 'parent' as SessionId,
@@ -870,11 +871,15 @@ describe('running and lock semantics', () => {
     }
     }
     const plain = bench({ running: true, busyEnter: 'steer', draft: 'plain', subagent })
     const plain = bench({ running: true, busyEnter: 'steer', draft: 'plain', subagent })
     fireEvent.keyDown(plain.textarea, { key: 'Enter' })
     fireEvent.keyDown(plain.textarea, { key: 'Enter' })
-    expect(plain.sink).toHaveBeenCalledWith('plain', [], 'queue', expect.any(AbortSignal))
+    expect(plain.sink).toHaveBeenCalledWith('plain', [], 'steer', expect.any(AbortSignal))
 
 
     const accelerated = bench({ running: true, draft: 'accelerated', subagent })
     const accelerated = bench({ running: true, draft: 'accelerated', subagent })
     fireEvent.keyDown(accelerated.textarea, { key: 'Enter', metaKey: true })
     fireEvent.keyDown(accelerated.textarea, { key: 'Enter', metaKey: true })
-    expect(accelerated.sink).toHaveBeenCalledWith('accelerated', [], 'queue', expect.any(AbortSignal))
+    expect(accelerated.sink).toHaveBeenCalledWith('accelerated', [], 'steer', expect.any(AbortSignal))
+
+    const opposite = bench({ running: true, busyEnter: 'steer', draft: 'opposite', subagent })
+    fireEvent.keyDown(opposite.textarea, { key: 'Enter', metaKey: true })
+    expect(opposite.sink).toHaveBeenCalledWith('opposite', [], 'queue', expect.any(AbortSignal))
   })
   })
 
 
   it('disabled (session removed) locks the textarea and chrome', () => {
   it('disabled (session removed) locks the textarea and chrome', () => {

+ 25 - 2
packages/client/ui-conversation/tests/queue-dock.client.spec.tsx

@@ -1,7 +1,7 @@
 // @vitest-environment jsdom
 // @vitest-environment jsdom
 /**
 /**
  * QueueDock rendering and operations: authoritative rows, inline editing,
  * QueueDock rendering and operations: authoritative rows, inline editing,
- * collapse state, removal, strict steering, failure notices, and live retirement.
+ * collapse state, removal, QueueDock Steer, failure notices, and live retirement.
  */
  */
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
 import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
@@ -486,7 +486,7 @@ describe('QueueDock', () => {
     expect(rendered.getByLabelText('插话发送').getAttribute('title')).toBe('仅运行中可插话发送')
     expect(rendered.getByLabelText('插话发送').getAttribute('title')).toBe('仅运行中可插话发送')
   })
   })
 
 
-  it('renders a session-backed subagent Queue without unsupported actions', () => {
+  it('renders ordinary queue actions for a continuable child', () => {
     const snap = {
     const snap = {
       ...snapshotWith([row('i-subagent', 'pending child follow-up')]),
       ...snapshotWith([row('i-subagent', 'pending child follow-up')]),
       subagent: {
       subagent: {
@@ -495,6 +495,29 @@ describe('QueueDock', () => {
           childSessionId: SID,
           childSessionId: SID,
           mode: 'continuable' as const,
           mode: 'continuable' as const,
         },
         },
+        parentAvailable: false,
+      },
+    }
+    const source = liveSession(snap)
+    const view = render(
+      <QueueDock {...kitFor(snap)} useSession={source.useSession} />,
+    )
+
+    expect(view.getByText('pending child follow-up')).toBeTruthy()
+    expect(view.getByLabelText('编辑排队消息')).toBeTruthy()
+    expect(view.getByLabelText('删除排队消息')).toBeTruthy()
+    expect(view.getByLabelText('插话发送')).toBeTruthy()
+  })
+
+  it('keeps a one-shot child Queue read-only', () => {
+    const snap = {
+      ...snapshotWith([row('i-subagent', 'pending child follow-up')]),
+      subagent: {
+        address: {
+          parentSessionId: 'parent' as SessionId,
+          childSessionId: SID,
+          mode: 'one-shot' as const,
+        },
         parentAvailable: true,
         parentAvailable: true,
       },
       },
     }
     }

+ 2 - 2
packages/client/ui-conversation/tests/service-orchestration.client.spec.ts

@@ -78,7 +78,7 @@ describe('ConversationController', () => {
     await b.runtime.dispose()
     await b.runtime.dispose()
   })
   })
 
 
-  it('treats strict-steer races as converged Queue delivery', async () => {
+  it('treats QueueDock Steer pre-admission races as converged Queue delivery', async () => {
     const b = await bench()
     const b = await bench()
     b.updateQueue.mockResolvedValueOnce({
     b.updateQueue.mockResolvedValueOnce({
       ok: false, error: new RemoteError('session/steer-unavailable', 'closed', { itemId: 'item-1' as QueuedMessage['id'] }),
       ok: false, error: new RemoteError('session/steer-unavailable', 'closed', { itemId: 'item-1' as QueuedMessage['id'] }),
@@ -822,7 +822,7 @@ describe('InputHub queue steering (empty-draft accelerated Enter)', () => {
     expect(b.shell.notices.getSnapshot()).toBeNull()
     expect(b.shell.notices.getSnapshot()).toBeNull()
 
 
     // A row the host already claimed (e.g. a repeated empty-draft chord):
     // A row the host already claimed (e.g. a repeated empty-draft chord):
-    // the duplicate strict steer is a silent no-op.
+    // the duplicate Steer is a silent no-op.
     await b.runtime.sessions.updateSessionSnapshot('s1', (draft) => {
     await b.runtime.sessions.updateSessionSnapshot('s1', (draft) => {
       draft.queue = [row('q-3')]
       draft.queue = [row('q-3')]
     })
     })

+ 3 - 3
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -2307,8 +2307,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       },
       },
       {
       {
         signature: '@Remote(\'prompt\') async prompt(request: SubagentPromptRequest, signal: AbortSignal): Promise<SubagentPromptReceipt>',
         signature: '@Remote(\'prompt\') async prompt(request: SubagentPromptRequest, signal: AbortSignal): Promise<SubagentPromptReceipt>',
-        description: 'Deliver one browser-authored message to a continuable child through the exact live direct parent, retaining the caller-minted request identity and validated browser zone on the accepted message. Success identifies the message the child\'s FIFO inbox accepted; later execution is independent of this call. Image parts are admitted and persisted through the attachment store before delivery, and the child\'s model must accept image input.',
-        parameters: [{ name: 'request', description: 'durable address, minted identity, content, and optional browser zone.' }, { name: 'signal', description: 'carrier cancellation, owning the call until inbox acceptance.' }],
+        description: 'Deliver one browser-authored message to a continuable child through the exact live direct parent, retaining the caller-minted request identity and validated browser zone on the accepted message. Success identifies the message the child\'s inbox accepted; later execution is independent of this call. Queue delivery targets a later turn; steer delivery targets the nearest step and retains the Agent loop\'s best-effort fallback semantics. Image parts are admitted and persisted through the attachment store before delivery, and the child\'s model must accept image input.',
+        parameters: [{ name: 'request', description: 'durable address, delivery, minted identity, content, and optional browser zone.' }, { name: 'signal', description: 'carrier cancellation, owning the call until inbox acceptance.' }],
         returns: 'the accepted message\'s inbox identity.',
         returns: 'the accepted message\'s inbox identity.',
         throws: ['{RemoteError} `gateway/bad-request`, `subagent/attachment-invalid`, `subagent/invalid-time-zone`, `subagent/parent-unavailable`, `subagent/not-resumable`, `subagent/unauthorized`, `subagent/delivery-unavailable`, `gateway/cancelled`, or `gateway/internal`.'],
         throws: ['{RemoteError} `gateway/bad-request`, `subagent/attachment-invalid`, `subagent/invalid-time-zone`, `subagent/parent-unavailable`, `subagent/not-resumable`, `subagent/unauthorized`, `subagent/delivery-unavailable`, `gateway/cancelled`, or `gateway/internal`.'],
       },
       },
@@ -5631,7 +5631,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   },
   {
   {
     name: 'SubagentPromptRequest',
     name: 'SubagentPromptRequest',
-    declaration: 'export interface SubagentPromptRequest {\n    readonly requestId: SubagentPromptRequestId;\n    readonly parentSessionId: SessionId;\n    readonly childSessionId: SessionId;\n    readonly mode: \'continuable\';\n    readonly content: readonly PromptContentPart[];\n    readonly clientTimeZone?: string;\n}',
+    declaration: 'export interface SubagentPromptRequest {\n    readonly requestId: SubagentPromptRequestId;\n    readonly parentSessionId: SessionId;\n    readonly childSessionId: SessionId;\n    readonly mode: \'continuable\';\n    readonly delivery: \'queue\' | \'steer\';\n    readonly content: readonly PromptContentPart[];\n    readonly clientTimeZone?: string;\n}',
   },
   },
   {
   {
     name: 'SubagentPromptRequestId',
     name: 'SubagentPromptRequestId',

+ 2 - 2
packages/subagent/subagent/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/subagent/subagent/README.md
 #   pnpm run verify-translation-pairing --write packages/subagent/subagent/README.md
-README.md: 9d99b27645e339de44d71c2df3f709fe28dc4f4b
-README.zh.md: 9397c8ae92f7ff69c6ebbb3672a9e96247a816e1
+README.md: 60aea7d446acf2e01f46132ab03354bf17f74213
+README.zh.md: e46ee53436256afa5f0c8dd1c635cbd0f9585927

+ 9 - 5
packages/subagent/subagent/README.md

@@ -48,7 +48,7 @@ One-shot children run once and settle with a single result, plus an optional str
 
 
 ### Messaging, interrupting, and discovering
 ### Messaging, interrupting, and discovering
 
 
-Every exact live Agent can use `sendMessage()` with a direct continuable child; a resident continuable child can also use it with its direct parent. A working target receives the message through Steer at its nearest step; an idle target starts a turn, and only a direct child can be cold-resumed. The parent can also interrupt a running descendant or list its children at any time. A browser continuation prompt may carry image parts: the Host admits and persists each image batch through the attachment store before the child inbox accepts the message, and refuses delivery when the child's declared model does not accept image input. Discovery covers both shapes: the service lists direct children and the full descendant tree — mode, activity, and lineage — reading live session state and optional persistence, without loading any child.
+Every exact live Agent can use `sendMessage()` with a direct continuable child; a resident continuable child can also use it with its direct parent. A working target receives the Agent message through Steer at its nearest step; an idle target starts a turn, and only a direct child can be cold-resumed. The parent can also interrupt a running descendant or list its children at any time. A browser continuation prompt independently selects Queue or Steer and may carry image parts: the Host admits and persists each image batch through the attachment store before the child inbox accepts the message, and refuses delivery when the child's declared model does not accept image input. Discovery covers both shapes: the service lists direct children and the full descendant tree — mode, activity, and lineage — reading live session state and optional persistence, without loading any child.
 
 
 ### Failure and recovery
 ### Failure and recovery
 
 
@@ -76,8 +76,11 @@ This section explains how the service is built and where the observable behavior
 | File | Role |
 | File | Role |
 |---|---|
 |---|---|
 | [`src/index.ts`](src/index.ts) | Service entry: provider registry, start and continuation API, lifecycle events |
 | [`src/index.ts`](src/index.ts) | Service entry: provider registry, start and continuation API, lifecycle events |
-| [`src/continuation.ts`](src/continuation.ts) | Continuable children: identity reservation, Activation residency, adjacent messaging, interrupt, settlement |
-| [`src/internal.ts`](src/internal.ts) | Host-only Queue and Steer adapters for browser and Team message protocols |
+| [`src/continuation.ts`](src/continuation.ts) | Continuable orchestration: identity reservation, provider preparation, cold resume, authorization, routing |
+| [`src/continuation-activation.ts`](src/continuation-activation.ts) | Process-local Activation graph, admission, settlement, and child-first disposal |
+| [`src/continuation-messages.ts`](src/continuation-messages.ts) | Adjacent-Agent messages, return guidance, and settlement notices |
+| [`src/internal.ts`](src/internal.ts) | Host-only Queue and Steer adapters plus standard adjacent-Agent messaging markers |
+| [`src/inbox.ts`](src/inbox.ts) | Activation-local Queue and Steer admission plus the synchronous closing cutoff |
 | [`src/types.ts`](src/types.ts) | Public request, result, and provider contracts |
 | [`src/types.ts`](src/types.ts) | Public request, result, and provider contracts |
 | [`src/descriptor.ts`](src/descriptor.ts) | Versioned `subagent/descriptor` session-event vocabulary |
 | [`src/descriptor.ts`](src/descriptor.ts) | Versioned `subagent/descriptor` session-event vocabulary |
 | [`src/child-agent.ts`](src/child-agent.ts) | Child composition, delegated policy, depth helpers |
 | [`src/child-agent.ts`](src/child-agent.ts) | Child composition, delegated policy, depth helpers |
@@ -91,7 +94,7 @@ A request is validated against the provider's advertised capabilities, a durable
 
 
 ### Continuable flow
 ### Continuable flow
 
 
-The manager reserves a child identity, resolves the durable descriptor, creates (or cold-resumes) the child Agent, installs it in an Activation, and submits the prompt. Model-authored messages cross one parent/child edge through fixed Steer scheduling; host protocols retain an internal Queue adapter for distinct turns. An absent direct-child Activation cold-resumes from the persisted session. When a resident Activation settles, the manager tells the child's direct parent in the parent's own turn stream.
+The manager reserves a child identity, resolves the durable descriptor, creates (or cold-resumes) the child Agent, installs it in an Activation, and submits the prompt. Model-authored messages cross one parent/child edge through fixed Steer scheduling; browser human prompts choose Queue or best-effort Steer through an internal adapter, while other host protocols may retain Queue for distinct turns. A Session queue command admits a live subagent-owned Agent only from its own continuable descriptor. Settlement waits for Agent activity to finish, an empty Inbox, and no owned children, then flushes final Session state with admission open. Under the child lock, the manager revalidates the wake generation, Session sequence, Inbox, and owned children; the synchronous task entry of `Agent.runMaintenance()` claims the idle phase and closes the private subagent Inbox in the same JavaScript turn before handle disposal. An absent direct-child Activation cold-resumes from the persisted session. When a resident Activation settles, the manager tells the child's direct parent in the parent's own turn stream.
 
 
 ### Ownership and invariants
 ### Ownership and invariants
 
 
@@ -163,9 +166,10 @@ Prefix-stable within a child: the statement never changes during the child's lif
 These limits define when the seam is a poor fit or needs special operational care. They are current package constraints, not a general delegation comparison or a task backlog.
 These limits define when the seam is a poor fit or needs special operational care. They are current package constraints, not a general delegation comparison or a task backlog.
 
 
 - **ACP children remain one-shot and are not trace-enumerable** — an ACP run has no local child session in the parent's session corpus, and remote providers need an Activation ownership contract before they can support continuable children.
 - **ACP children remain one-shot and are not trace-enumerable** — an ACP run has no local child session in the parent's session corpus, and remote providers need an Activation ownership contract before they can support continuable children.
-- **Adjacent model messaging only** — `sendMessage()` requires an exact live sender; every sender may target a direct continuable child, while only a sender with a resident continuable Activation may target its direct parent. Browser prompts use the separate Queue control path.
+- **Adjacent model messaging only** — `sendMessage()` requires an exact live sender; every sender may target a direct continuable child, while only a sender with a resident continuable Activation may target its direct parent. Browser prompts use a separate human Queue-or-Steer control path.
 - **A direct parent must remain live for child-to-parent delivery** — the service has no durable parent mailbox; a missing parent rejects the message instead of accepting work it cannot wake.
 - **A direct parent must remain live for child-to-parent delivery** — the service has no durable parent mailbox; a missing parent rejects the message instead of accepting work it cannot wake.
 - **Wake gap during cancellation convergence** — a follow-up accepted after an interrupt signal but before the driver becomes idle stays queued until another waking send.
 - **Wake gap during cancellation convergence** — a follow-up accepted after an interrupt signal but before the driver becomes idle stays queued until another waking send.
+- **Pending injected context retains an Activation** — settlement conservatively treats every Inbox occurrence as unfinished. Context parked after the Agent becomes idle keeps the child and its live ancestors resident until a waking delivery claims it, a queue mutation removes it, or manager teardown discards it.
 - **Process-local residency** — the Activation inbox and ownership graph do not coordinate two harness processes; concurrent access to one persistence store needs a durable mailbox and cross-process lease protocol.
 - **Process-local residency** — the Activation inbox and ownership graph do not coordinate two harness processes; concurrent access to one persistence store needs a durable mailbox and cross-process lease protocol.
 - **No replay of accepted-but-unlogged messages** — a crash can lose an accepted prompt that never reached the child's session log; the lost message is not replayed automatically.
 - **No replay of accepted-but-unlogged messages** — a crash can lose an accepted prompt that never reached the child's session log; the lost message is not replayed automatically.
 - **No durable parent mailbox** — child-to-parent messages require a resident continuable child and live direct parent, and provide acceptance identity rather than exactly-once delivery.
 - **No durable parent mailbox** — child-to-parent messages require a resident continuable child and live direct parent, and provide acceptance identity rather than exactly-once delivery.

+ 9 - 5
packages/subagent/subagent/README.zh.md

@@ -48,7 +48,7 @@ kind: "package-reference"
 
 
 ### 消息、中断与发现
 ### 消息、中断与发现
 
 
-每个确切在线 Agent 都可以对直接可继续 child 使用 `sendMessage()`;驻留的可继续 child 还可以对自己的直接 parent 使用它。正在工作的目标通过 Steer 在最近 step 接收消息;空闲目标启动轮次,且只有直接 child 可以冷恢复。parent 也可以随时中断正在运行的后代或列举自己的子级。浏览器发出的继续执行 prompt 可以携带图片部分:Host 先通过附件存储完成整批图片的准入与持久化,子级 inbox 才接受这条消息;当子级声明的模型不接受图片输入时拒绝投递。发现覆盖两种形态:服务列举直接子级与完整后代树——模式、活动状态与血缘——直接读取在线会话状态与可选持久化,不加载任何子 agent。
+每个确切在线 Agent 都可以对直接可继续 child 使用 `sendMessage()`;驻留的可继续 child 还可以对自己的直接 parent 使用它。正在工作的目标通过 Steer 在最近 step 接收 Agent 消息;空闲目标启动轮次,且只有直接 child 可以冷恢复。parent 也可以随时中断正在运行的后代或列举自己的子级。浏览器发出的继续执行 prompt 会独立选择 Queue 或 Steer,并且可以携带图片部分:Host 先通过附件存储完成整批图片的准入与持久化,子级 inbox 才接受这条消息;当子级声明的模型不接受图片输入时拒绝投递。发现覆盖两种形态:服务列举直接子级与完整后代树——模式、活动状态与血缘——直接读取在线会话状态与可选持久化,不加载任何子 agent。
 
 
 ### 失败与恢复
 ### 失败与恢复
 
 
@@ -76,8 +76,11 @@ kind: "package-reference"
 | 文件 | 职责 |
 | 文件 | 职责 |
 |---|---|
 |---|---|
 | [`src/index.ts`](src/index.ts) | 服务入口:提供方注册表、启动与继续 API、生命周期事件 |
 | [`src/index.ts`](src/index.ts) | 服务入口:提供方注册表、启动与继续 API、生命周期事件 |
-| [`src/continuation.ts`](src/continuation.ts) | 可继续子级:身份预留、Activation 驻留、相邻消息、中断、结算 |
-| [`src/internal.ts`](src/internal.ts) | 供浏览器与 Team 消息协议使用的 host-only Queue 与 Steer 适配器 |
+| [`src/continuation.ts`](src/continuation.ts) | 可继续子级编排:身份预留、提供方准备、冷恢复、授权与路由 |
+| [`src/continuation-activation.ts`](src/continuation-activation.ts) | 进程内 Activation 图、准入、结算与子级优先释放 |
+| [`src/continuation-messages.ts`](src/continuation-messages.ts) | 相邻 Agent 消息、返回指引与结算通知 |
+| [`src/internal.ts`](src/internal.ts) | Host 专用 Queue 与 Steer 适配器,以及标准相邻 Agent 消息标记 |
+| [`src/inbox.ts`](src/inbox.ts) | Activation 局部的 Queue 和 Steer 准入,以及同步 closing cutoff |
 | [`src/types.ts`](src/types.ts) | 公开的请求、结果与提供方约定 |
 | [`src/types.ts`](src/types.ts) | 公开的请求、结果与提供方约定 |
 | [`src/descriptor.ts`](src/descriptor.ts) | 版本化的 `subagent/descriptor` 会话事件词汇 |
 | [`src/descriptor.ts`](src/descriptor.ts) | 版本化的 `subagent/descriptor` 会话事件词汇 |
 | [`src/child-agent.ts`](src/child-agent.ts) | 子级组装、委派策略、深度辅助函数 |
 | [`src/child-agent.ts`](src/child-agent.ts) | 子级组装、委派策略、深度辅助函数 |
@@ -91,7 +94,7 @@ kind: "package-reference"
 
 
 ### 可继续流程
 ### 可继续流程
 
 
-管理器预留 child 身份、解析持久化描述符、创建(或冷恢复)child、把它安装进 Activation 并提交提示词。模型编写的消息通过固定 Steer 调度跨一条 parent/child 边;host 协议保留内部 Queue 适配器以创建独立轮次。直接 child 不存在 Activation 时会从持久化会话冷恢复。当驻留 Activation 结算时,管理器会在 parent 自身的轮次流中告知该 child 的直接 parent。
+管理器预留 child 身份、解析持久化描述符、创建(或冷恢复)child、把它安装进 Activation 并提交提示词。模型编写的消息通过固定 Steer 调度跨一条 parent/child 边;浏览器人类 prompt 通过内部适配器选择 Queue 或 best-effort Steer,其他 host 协议仍可保留 Queue 以创建独立轮次。Session queue command 仅根据 child 自身的 continuable descriptor 准入在线 subagent-owned Agent。Settlement 会等待 Agent 活动结束、Inbox 为空且没有所拥有子级,再在准入开放时 flush 最终 Session 状态。管理器随后在 child lock 内重新验证 wake generation、Session 序号、Inbox 与所拥有子级;`Agent.runMaintenance()` 的同步 task 入口会占用 idle 阶段,并在同一个 JavaScript turn 内关闭私有 subagent Inbox,然后才 dispose handle。直接 child 不存在 Activation 时会从持久化会话冷恢复。当驻留 Activation 结算时,管理器会在 parent 自身的轮次流中告知该 child 的直接 parent。
 
 
 ### 所有权与不变式
 ### 所有权与不变式
 
 
@@ -163,9 +166,10 @@ You are a delegated subagent: your permission scope was fixed when you were star
 这些限制说明该 seam 何时不合适,或何时需要特别的运维注意。它们是当前包约束,不是通用委派对比或任务积压。
 这些限制说明该 seam 何时不合适,或何时需要特别的运维注意。它们是当前包约束,不是通用委派对比或任务积压。
 
 
 - **ACP 子级仍为一次性,且无法通过追踪枚举**——ACP 运行在父级会话语料中没有本地子会话,远程提供方需要 Activation 所有权约定才能支持可继续子级。
 - **ACP 子级仍为一次性,且无法通过追踪枚举**——ACP 运行在父级会话语料中没有本地子会话,远程提供方需要 Activation 所有权约定才能支持可继续子级。
-- **仅允许相邻模型消息**——`sendMessage()` 要求确切在线 sender;每个 sender 都可以指定直接可继续 child,只有具备驻留可继续 Activation 的 sender 可以指定自己的直接 parent。浏览器提示使用独立的 Queue 控制路径。
+- **仅允许相邻模型消息**——`sendMessage()` 要求确切在线 sender;每个 sender 都可以指定直接可继续 child,只有具备驻留可继续 Activation 的 sender 可以指定自己的直接 parent。浏览器提示使用独立的人类 Queue 或 Steer 控制路径。
 - **child 到 parent 的投递要求直接 parent 保持在线**——服务没有持久 parent mailbox;parent 缺失时会拒绝消息,而非接受无法唤醒的工作。
 - **child 到 parent 的投递要求直接 parent 保持在线**——服务没有持久 parent mailbox;parent 缺失时会拒绝消息,而非接受无法唤醒的工作。
 - **取消收敛期间存在唤醒缺口**——中断信号发出后、driver 进入 idle 前被接受的后续消息会保持排队,直到另一条唤醒发送到达。
 - **取消收敛期间存在唤醒缺口**——中断信号发出后、driver 进入 idle 前被接受的后续消息会保持排队,直到另一条唤醒发送到达。
+- **待处理的注入 context 会保留 Activation**——settlement 会保守地把每个 Inbox occurrence 都视为未完成。Agent 进入 idle 后停放的 context 会让 child 及其在线祖先继续驻留,直到唤醒投递将其 claim、queue 变更将其移除,或 manager teardown 将其丢弃。
 - **驻留仅限进程内**——Activation inbox 与所有权图不会在两个 harness 进程之间协调;对单个持久化存储的并发访问需要持久化邮箱与跨进程租约协议。
 - **驻留仅限进程内**——Activation inbox 与所有权图不会在两个 harness 进程之间协调;对单个持久化存储的并发访问需要持久化邮箱与跨进程租约协议。
 - **不回放已接受但未记录的消息**——崩溃可能丢失从未写入子会话日志、已被接受的提示词;丢失的消息不会自动回放。
 - **不回放已接受但未记录的消息**——崩溃可能丢失从未写入子会话日志、已被接受的提示词;丢失的消息不会自动回放。
 - **没有持久化 parent mailbox**——child 到 parent 的消息要求驻留的可继续 child 与在线直接 parent,提供的是接受标识,不保证恰好一次投递。
 - **没有持久化 parent mailbox**——child 到 parent 的消息要求驻留的可继续 child 与在线直接 parent,提供的是接受标识,不保证恰好一次投递。

+ 854 - 0
packages/subagent/subagent/src/continuation-activation.ts

@@ -0,0 +1,854 @@
+/**
+ * Process-local Activation ownership for continuable subagents: admission,
+ * parent-child residency, serialized delivery, settlement, and disposal.
+ *
+ * The continuation manager owns durable request orchestration and delegates
+ * every mutable residency decision to this registry, so delivery and teardown
+ * share one child lock and one Activation map.
+ *
+ * @module @deepseek-ai/dsh-subagent/continuation-activation
+ */
+
+import type { Context } from '@deepseek-ai/cordis'
+import type {
+  Agent,
+  AgentHandle,
+  AgentOptions,
+  CreateAgentOptions,
+} from '@deepseek-ai/dsh-agent'
+import { errorChain } from '@deepseek-ai/dsh-llm'
+import type { MessageId } from '@deepseek-ai/dsh-llm'
+import type {
+  SessionEvent,
+  SessionId,
+  SessionLogOffset as SessionLogOffsetType,
+  UserMessage,
+} from '@deepseek-ai/dsh-session'
+import type { ToolRestriction } from '@deepseek-ai/dsh-tools'
+import {
+  appendDelegatedPolicyOverrides,
+  applyChildComposition,
+} from './child-agent.ts'
+import type { DelegatedPolicyOverrides } from './child-agent.ts'
+import { createSettlementMessage } from './continuation-messages.ts'
+import type { SubagentDescriptorData } from './descriptor.ts'
+import { SubagentError } from './error.ts'
+import { SubagentInbox } from './inbox.ts'
+import type { SubagentDelivery } from './inbox.ts'
+import type { ActivationObserver, ActivationTerminal } from './lifecycle.ts'
+
+/**
+ * One residency epoch for a reconstructed continuable child Agent. It directly
+ * owns the published `AgentHandle`; the registry's private activation-owner
+ * scope is its structural Cordis owner.
+ */
+export interface Activation {
+  /** The durable child this Activation is an epoch of. */
+  readonly childId: SessionId
+  /**
+   * The durable direct parent, stored because settlement delivery must resolve
+   * that parent after the child handle is gone. {@link ancestry} cannot answer
+   * it: a `WeakSet` is not enumerable, and the child's own header is only
+   * reachable through a handle disposal has already released.
+   */
+  readonly parentSession: SessionId
+  /** The provider name recorded in the durable descriptor. */
+  readonly provider: string
+  /** The retained live Agent handle, disposed exactly once at settlement. */
+  readonly handle: AgentHandle
+  /** The Activation-local admission and close wrapper around the handle's Agent inbox. */
+  readonly inbox: SubagentInbox
+  /**
+   * Exact live Agent ancestry observed when this Activation materialized.
+   * Weak membership preserves host-scope identity across an intermediate
+   * ancestor leaving the registry without retaining that ancestor's runtime.
+   */
+  readonly ancestry: WeakSet<Agent>
+  /**
+   * Session ids of the child Activations this one owns. Because one Session has
+   * at most one live Activation, the id identifies the live child without
+   * another runtime-incarnation reference. Non-empty blocks settlement.
+   */
+  readonly ownedChildren: Set<SessionId>
+  /** The lifecycle observer that emits this epoch's start and terminal edges. */
+  readonly observer: ActivationObserver
+  /**
+   * Whether any delivery to this child was ever accepted. A materialization
+   * rolled back before its first acceptance is a child the caller was told does
+   * not exist, so its teardown owes the parent no settlement account.
+   */
+  announced: boolean
+  /** Renewed whenever a settlement watcher must re-check residency state. */
+  poke: PromiseWithResolvers<void>
+}
+
+/** Inputs shared by fresh and resumed Activation materialization. */
+export interface MaterializeInputs {
+  childId: SessionId
+  provider: string
+  parent: Agent
+  /**
+   * Creation inputs; absent for a cold resume, which loads the persisted
+   * session — including the delegation policy events a fresh creation seeded,
+   * so a resume never re-captures the parent's policy.
+   */
+  create?: {
+    seed: readonly SessionEvent[] | undefined
+    meta: NonNullable<CreateAgentOptions['meta']>
+    /** Exact parent-log prefix length inside {@link seed}. */
+    inheritedEventCount: SessionLogOffsetType
+    /** Policy captured at delegation: the parent's sandbox override plus the approval pin. */
+    delegatedPolicies: DelegatedPolicyOverrides
+    /** Child-owned composition record appended after the inherited marker. */
+    descriptor: SubagentDescriptorData
+  }
+  agentOptions: AgentOptions
+  composition: { persona?: string | undefined; toolFilter?: ToolRestriction | undefined }
+  signal: AbortSignal
+}
+
+/**
+ * One admitted materialization and the exact live ancestry observed at its
+ * synchronous admission point. Retaining identities lets a scoped teardown
+ * keep waiting even if an intermediate Agent leaves the registry meanwhile.
+ */
+interface Materialization {
+  readonly lineage: readonly Agent[]
+  readonly settled: Promise<void>
+}
+
+/** Residency state observed by the natural-settlement watcher. */
+type SettlementState = 'closed' | 'retry' | 'wait' | 'ready'
+
+/** Result of the final child-lock settlement decision. */
+type SettlementAttempt =
+  | Exclude<SettlementState, 'ready'>
+  | { readonly done: Promise<void> }
+
+/** Serialize each durable child's delivery, release, and disposal. */
+export class ChildLock {
+  private tails = new Map<SessionId, Promise<unknown>>()
+
+  /**
+   * Run `operation` after every previously queued operation for `childId`.
+   * @param childId - the durable child whose operations are linearized.
+   * @param operation - the critical section to run in order.
+   * @returns the operation's own settlement.
+   */
+  run<T>(childId: SessionId, operation: () => Promise<T>): Promise<T> {
+    const previous = this.tails.get(childId) ?? Promise.resolve()
+    const result = previous.then(operation, operation)
+    // Absorb rejections in the chaining tail so one failed critical section
+    // cannot reject an unrelated later caller.
+    const tail = result.then(() => undefined, () => undefined)
+    this.tails.set(childId, tail)
+    void tail.then(() => {
+      if (this.tails.get(childId) === tail) this.tails.delete(childId)
+    })
+    return result
+  }
+}
+
+/** Own the complete process-local lifetime of continuable child Activations. */
+export class ContinuableActivationRegistry {
+  /** Child session id → its live Activation. Process-local, never durable. */
+  private readonly resident = new Map<SessionId, Activation>()
+  /** Materializations admitted before drain, tracked through publication or rollback. */
+  private readonly materializations = new Set<Materialization>()
+  /** Per-child serializer shared by delivery, release, and disposal. */
+  readonly locks = new ChildLock()
+  /** Structural Cordis owner of every Activation handle. */
+  readonly ownerCtx: Context
+  /**
+   * Exact roots whose host teardown has begun, with the live lineage members
+   * observed under each root. Entries remain until that exact root leaves the
+   * Agent registry, closing admission throughout its host's teardown without
+   * poisoning a later same-id replacement.
+   */
+  private readonly closingScopes = new Map<Agent, Set<Agent>>()
+  private draining = false
+
+  /**
+   * Build one registry inside the service's Agent-injected context.
+   * @param ctx - context providing Agents, Sessions, and teardown ownership.
+   * @param observeActivation - build the lifecycle observer for one residency epoch.
+   */
+  constructor(
+    private readonly ctx: Context,
+    private readonly observeActivation: (
+      provider: string,
+      childId: SessionId,
+      parent: Agent,
+    ) => ActivationObserver,
+  ) {
+    // Ordinary Cordis owner effects unwind in reverse registration order, which
+    // cannot express the dynamic child graph. Register the private scope's
+    // structural disposer FIRST and the drain SECOND, so reverse unwind invokes
+    // the drain before releasing the scope; a cleanup effect on the same scope
+    // as the Agent handles would let structural handle disposal bypass
+    // child-first ordering.
+    const scope = ctx.plugin(function activationOwner() {})
+    this.ownerCtx = scope.ctx
+    ctx.on('agent/disposed', ({ agent }) => {
+      this.closingScopes.delete(agent)
+    })
+    ctx.effect(function* (this: ContinuableActivationRegistry) {
+      yield scope.dispose
+      yield () => this.drain()
+    }.bind(this), 'subagents.continuations()')
+  }
+
+  /**
+   * Return the live Activation for a durable child id, if resident.
+   * @param childId - durable child session id to look up.
+   * @returns the process-local Activation, or `undefined` when it is not resident.
+   */
+  get(childId: SessionId): Activation | undefined {
+    return this.resident.get(childId)
+  }
+
+  /**
+   * Reject one child identity already owned by a live Agent or Session.
+   * @param childId - proposed durable child session id.
+   */
+  assertChildIdAvailable(childId: SessionId): void {
+    if (this.ctx.agents.get(childId) !== undefined || this.ctx.get('sessions')?.get(childId) !== undefined) {
+      throw new SubagentError(`subagent "${childId}" already exists`, 'DUPLICATE_CHILD')
+    }
+  }
+
+  /**
+   * Pre-register `childId` in a continuation-managed parent's owned set so the
+   * parent cannot settle while a caller is still establishing or resuming that
+   * child. Returns a releaser for the failure path; it removes only a hold
+   * this call added, and leaves ownership in place once a live Activation for
+   * the child exists.
+   * @param parent - the live direct parent the operation is admitted under.
+   * @param childId - the durable child the operation addresses.
+   * @returns the failure-path releaser; a no-op when nothing was added.
+   */
+  holdOwnership(parent: Agent, childId: SessionId): () => void {
+    const parentActivation = this.resident.get(parent.id)
+    if (parentActivation === undefined || parentActivation.handle.agent !== parent) return () => {}
+    if (parentActivation.inbox.closing !== undefined) {
+      throw new SubagentError(
+        `subagent parent "${parent.id}" is being disposed; the child was not established`,
+        'ACTIVATION_CLOSING',
+      )
+    }
+    if (parentActivation.ownedChildren.has(childId)) return () => {}
+    parentActivation.ownedChildren.add(childId)
+    return () => {
+      const live = this.resident.get(childId)
+      /* v8 ignore next 4 -- reaching this arm needs another delivery to establish the child
+       * between this operation's failure and its releaser running, which no test can schedule
+       * deterministically: the ownership edge then belongs to that live Activation, so the
+       * conservative keep leaves it for finishDisposal's releaseOwnership. */
+      if (live !== undefined && live.inbox.closing === undefined) return
+      if (parentActivation.ownedChildren.delete(childId)) this.wake(parentActivation)
+    }
+  }
+
+  /**
+   * Interrupt one live continuable child's current turn under the supplied authority.
+   * @param targetSessionId - the durable child session id to interrupt.
+   * @param authority - the human parent address or exact live ancestor Agent.
+   */
+  interrupt(
+    targetSessionId: SessionId,
+    authority:
+      | { readonly kind: 'user'; readonly parentSessionId: SessionId }
+      | { readonly kind: 'ancestor'; readonly agent: Agent },
+  ): void {
+    if (authority.kind === 'ancestor') {
+      const caller = authority.agent
+      if (this.ctx.agents.get(caller.id) !== caller) {
+        throw new SubagentError(
+          `interrupting "${targetSessionId}" requires the exact live ancestor agent`,
+          'UNAUTHORIZED',
+        )
+      }
+      if (caller.id === targetSessionId) {
+        throw new SubagentError(
+          `agent "${caller.id}" cannot interrupt itself`,
+          'UNAUTHORIZED',
+        )
+      }
+    }
+    const activation = this.resident.get(targetSessionId)
+    if (activation === undefined) return
+    if (authority.kind === 'user') {
+      if (activation.handle.agent.session.header.parentSession !== authority.parentSessionId) {
+        throw new SubagentError(
+          `subagent "${targetSessionId}" belongs to another parent session`,
+          'UNAUTHORIZED',
+        )
+      }
+    } else if (!activation.ancestry.has(authority.agent)) {
+      throw new SubagentError(
+        `subagent "${targetSessionId}" is not a live descendant of agent "${authority.agent.id}"`,
+        'UNAUTHORIZED',
+      )
+    }
+    // Disposal already stopped the target with a whole-Activation teardown;
+    // a second cancel would be a redundant signal on a closing handle.
+    if (activation.inbox.closing !== undefined) return
+    activation.handle.agent.cancel(
+      authority.kind === 'user' ? { kind: 'user' } : { kind: 'parent' },
+      { keepInbox: true },
+    )
+  }
+
+  /**
+   * Send through a receiving parent's Activation inbox when it has one.
+   * @param parent - exact live Agent receiving the message.
+   * @param message - durable user message to deliver.
+   * @param delivery - receiving inbox destination.
+   */
+  sendWaking(parent: Agent, message: UserMessage, delivery: SubagentDelivery): void {
+    const parentActivation = this.resident.get(parent.id)
+    if (parentActivation !== undefined && parentActivation.handle.agent === parent) {
+      try {
+        parentActivation.inbox.deliver(message, delivery)
+      } finally {
+        this.wake(parentActivation)
+      }
+      return
+    }
+    if (delivery === 'steer') parent.steer(message)
+    else parent.followup(message)
+  }
+
+  /**
+   * Close admission, await every already-admitted materialization through
+   * publication or rollback, then dispose the stable live Activation graph
+   * child-first.
+   */
+  async drain(): Promise<void> {
+    this.draining = true
+    await Promise.all([...this.materializations].map(materialization => materialization.settled))
+    const owned = new Set<SessionId>()
+    for (const activation of this.resident.values()) {
+      for (const child of activation.ownedChildren) owned.add(child)
+    }
+    const roots = [...this.resident.values()].filter(activation => !owned.has(activation.childId))
+    await this.disposeRoots(roots, 'activation(s)')
+  }
+
+  /**
+   * Stop only the continuable descendants of exact live host-owned parents.
+   * @param parents - exact live roots whose continuable descendants must stop.
+   */
+  async drainDescendants(parents: readonly Agent[]): Promise<void> {
+    const roots = new Set(parents.filter(parent => this.ctx.agents.get(parent.id) === parent))
+    if (roots.size === 0) return
+
+    for (const root of roots) {
+      this.closingMembers(root).add(root)
+    }
+
+    const targets: Activation[] = []
+    for (const activation of this.resident.values()) {
+      const lineage = this.liveLineage(activation.handle.agent)
+      const owners = [...roots].filter(root => activation.handle.agent !== root
+        && activation.ancestry.has(root))
+      if (owners.length === 0) continue
+      targets.push(activation)
+      for (const owner of owners) {
+        const members = this.closingMembers(owner)
+        members.add(activation.handle.agent)
+        for (const agent of lineage) members.add(agent)
+      }
+    }
+    const materializations = [...this.materializations].filter((materialization) => {
+      const owners = [...roots].filter(root => materialization.lineage.includes(root))
+      for (const owner of owners) {
+        const members = this.closingMembers(owner)
+        for (const agent of materialization.lineage) members.add(agent)
+      }
+      return owners.length > 0
+    })
+
+    const ownedTargets = new Set<SessionId>()
+    for (const activation of targets) {
+      for (const child of activation.ownedChildren) ownedTargets.add(child)
+    }
+    const targetRoots = targets.filter(activation => !ownedTargets.has(activation.childId))
+
+    for (const activation of targets) {
+      const disposal = this.dispose(activation)
+      void disposal.catch(() => undefined)
+    }
+
+    await Promise.all(materializations.map(materialization => materialization.settled))
+    await this.disposeRoots(targetRoots, 'scoped activation(s)')
+  }
+
+  /**
+   * Release selected resident direct children of one exact live parent.
+   * @param parent - exact live direct parent authorizing the selected release.
+   * @param childIds - durable direct-child ids to release when resident.
+   */
+  async drainChildren(parent: Agent, childIds: readonly SessionId[]): Promise<void> {
+    if (this.ctx.agents.get(parent.id) !== parent) {
+      throw new SubagentError('selected child teardown requires the exact live parent agent', 'UNAUTHORIZED')
+    }
+    const targets: Activation[] = []
+    for (const childId of new Set(childIds)) {
+      const activation = this.resident.get(childId)
+      if (activation === undefined) continue
+      if (activation.parentSession !== parent.id || !activation.ancestry.has(parent)) {
+        throw new SubagentError(
+          `subagent "${childId}" is not a direct child of agent "${parent.id}"`,
+          'UNAUTHORIZED',
+        )
+      }
+      targets.push(activation)
+    }
+
+    for (const activation of targets) {
+      const disposal = this.dispose(activation)
+      void disposal.catch(() => undefined)
+    }
+    await this.disposeRoots(targets, 'selected activation(s)')
+  }
+
+  /**
+   * Reject new admission once the registry or this exact parent tree began draining.
+   * @param agent - exact live Agent whose lineage determines admission.
+   */
+  assertAdmitting(agent: Agent): void {
+    const closing = this.closingTeardownFor(agent)
+    if (closing === undefined) return
+    throw new SubagentError(
+      closing === 'manager'
+        ? 'continuable subagents are draining; the operation was not admitted'
+        : `continuable subagents below parent "${closing.id}" are draining; the operation was not admitted`,
+      'DRAINING',
+    )
+  }
+
+  /**
+   * Authorize one operation against the durable direct-parent lineage.
+   * @param parent - exact live Agent claiming direct-parent authority.
+   * @param childId - durable child session id addressed by the operation.
+   * @param parentSession - durable direct-parent id recorded by the child.
+   */
+  authorizeLineage(
+    parent: Agent,
+    childId: SessionId,
+    parentSession: SessionId | undefined,
+  ): void {
+    if (this.ctx.agents.get(parent.id) !== parent) {
+      throw new SubagentError(
+        `subagent "${childId}" delivery requires the exact live parent agent`,
+        'UNAUTHORIZED',
+      )
+    }
+    if (parentSession !== parent.id) {
+      throw new SubagentError(`subagent "${childId}" belongs to another parent session`, 'UNAUTHORIZED')
+    }
+  }
+
+  /**
+   * Create or resume one child Agent and publish its Activation.
+   * @param inputs - reconstruction and admission inputs for the residency epoch.
+   * @returns the published process-local Activation.
+   */
+  materialize(inputs: MaterializeInputs): Promise<Activation> {
+    this.assertAdmitting(inputs.parent)
+    const settled = Promise.withResolvers<void>()
+    const lineage = this.liveLineage(inputs.parent)
+    const materialization: Materialization = {
+      lineage,
+      settled: settled.promise,
+    }
+    this.materializations.add(materialization)
+    return this.materializeTracked(inputs, lineage).finally(() => {
+      this.materializations.delete(materialization)
+      settled.resolve()
+    })
+  }
+
+  /**
+   * Cross the final admission cutoff and submit without yielding.
+   * @param activation - the exact resident child receiving the message.
+   * @param message - the already-built durable user message.
+   * @param delivery - the Agent inbox destination.
+   * @param parent - exact live direct parent authorizing admission.
+   * @param signal - caller cancellation before inbox acceptance.
+   * @returns the accepted durable message id.
+   */
+  submitAdmitted(
+    activation: Activation,
+    message: UserMessage,
+    delivery: SubagentDelivery,
+    parent: Agent,
+    signal: AbortSignal,
+  ): MessageId {
+    signal.throwIfAborted()
+    this.assertAdmitting(parent)
+    this.authorizeLineage(
+      parent,
+      activation.childId,
+      activation.handle.agent.session.header.parentSession,
+    )
+    this.acquireOwnership(parent, activation.childId)
+    try {
+      activation.inbox.deliver(message, delivery)
+    } finally {
+      this.wake(activation)
+    }
+    activation.announced = true
+    return message.id
+  }
+
+  /**
+   * Stop and release one Activation through its memoized close transaction.
+   * @param activation - exact residency epoch to close.
+   * @param finalStateFlushed - whether natural settlement already flushed final state.
+   * @returns the shared close transaction.
+   */
+  dispose(activation: Activation, finalStateFlushed = false): Promise<void> {
+    return activation.inbox.close(() => this.finishDisposal(activation, finalStateFlushed))
+  }
+
+  /** Dispose independent roots and report every branch failure after all settle. */
+  private async disposeRoots(
+    roots: readonly Activation[],
+    failureSubject: 'activation(s)' | 'scoped activation(s)' | 'selected activation(s)',
+  ): Promise<void> {
+    const failures = await Promise.all(roots.map(async (activation) => {
+      try {
+        await this.dispose(activation)
+        return undefined
+      } catch (error: unknown) {
+        return error
+      }
+    }))
+    const reasons = failures.filter(failure => failure !== undefined)
+    if (reasons.length > 0) {
+      throw new SubagentError(
+        `continuable subagent teardown failed for ${reasons.length} ${failureSubject}: `
+        + reasons.map(reason => errorChain(reason)).join('; '),
+        'ACTIVATION_TEARDOWN_FAILED',
+      )
+    }
+  }
+
+  /** Return the retained member set for one exact scoped-teardown root. */
+  private closingMembers(root: Agent): Set<Agent> {
+    const existing = this.closingScopes.get(root)
+    if (existing !== undefined) return existing
+    const members = new Set<Agent>()
+    this.closingScopes.set(root, members)
+    return members
+  }
+
+  /** Return the exact currently resolvable ancestry from `agent` upward. */
+  private liveLineage(agent: Agent): Agent[] {
+    const lineage = [agent]
+    const seen = new Set<SessionId>([agent.id])
+    let parentSession = agent.session.header.parentSession
+    while (parentSession !== undefined) {
+      const parent = this.ctx.agents.get(parentSession)
+      if (parent === undefined || seen.has(parent.id)) break
+      lineage.push(parent)
+      seen.add(parent.id)
+      parentSession = parent.session.header.parentSession
+    }
+    return lineage
+  }
+
+  /** Return the teardown that closed continuable admission for this agent's lineage. */
+  private closingTeardownFor(agent: Agent): Agent | 'manager' | undefined {
+    if (this.draining) return 'manager'
+    const lineage = this.liveLineage(agent)
+    for (const [root, members] of this.closingScopes) {
+      if (members.has(agent) || lineage.includes(root)) return root
+    }
+    return undefined
+  }
+
+  /** Perform one tracked materialization through publication or rollback. */
+  private async materializeTracked(
+    inputs: MaterializeInputs,
+    parentLineage: readonly Agent[],
+  ): Promise<Activation> {
+    const { childId, provider, parent, create } = inputs
+    inputs.signal.throwIfAborted()
+    const setup = (childCtx: Context): void => {
+      const child = childCtx.agent as Agent
+      // Only fresh creation appends the descriptor and delegated policy after
+      // the inherited marker; a cold resume replays those persisted events.
+      if (create !== undefined) {
+        child.session.append('subagent/descriptor', create.descriptor)
+        appendDelegatedPolicyOverrides(child.session, create.delegatedPolicies)
+      }
+      applyChildComposition(childCtx, parent, inputs.composition)
+    }
+    const observer = this.observeActivation(provider, childId, parent)
+    const handle: AgentHandle = create === undefined
+      ? await this.ownerCtx.agents.resume({
+        resumeSessionId: childId,
+        agentOptions: inputs.agentOptions,
+        signal: inputs.signal,
+        setup,
+      })
+      : await this.ownerCtx.agents.create({
+        sessionId: childId,
+        meta: create.meta,
+        ...(create.seed === undefined ? {} : { seed: create.seed }),
+        inheritedEventCount: create.inheritedEventCount,
+        agentOptions: inputs.agentOptions,
+        signal: inputs.signal,
+        setup,
+      })
+
+    const activation: Activation = {
+      childId,
+      parentSession: parent.id,
+      provider,
+      handle,
+      inbox: new SubagentInbox(handle.agent),
+      ancestry: new WeakSet([handle.agent, ...parentLineage]),
+      ownedChildren: new Set(),
+      observer,
+      announced: false,
+      poke: Promise.withResolvers<void>(),
+    }
+    this.resident.set(childId, activation)
+    try {
+      inputs.signal.throwIfAborted()
+      this.assertAdmitting(parent)
+      this.acquireOwnership(parent, childId)
+      const wakeOnInboxRemoval = (): void => { this.wake(activation) }
+      handle.agent.ctx.on('agent/inbox/claimed', wakeOnInboxRemoval)
+      handle.agent.ctx.on('agent/inbox/discarded', wakeOnInboxRemoval)
+      observer.start(handle.agent)
+    } catch (error: unknown) {
+      /* v8 ignore next -- rollback failure must not mask the admission failure
+       * that prevented this operation from returning an accepted message id. */
+      await this.rollbackUnpublished(activation).catch(() => undefined)
+      throw error
+    }
+    this.watchSettlement(activation)
+    return activation
+  }
+
+  /** Release an Activation whose start edge was not published. */
+  private rollbackUnpublished(activation: Activation): Promise<void> {
+    return activation.inbox.close(async () => {
+      try {
+        await activation.handle.dispose()
+      } finally {
+        this.resident.delete(activation.childId)
+        this.releaseOwnership(activation.childId)
+      }
+    })
+  }
+
+  /** Register the child in a continuation-managed parent's owned set. */
+  private acquireOwnership(parent: Agent, childId: SessionId): void {
+    const parentActivation = this.resident.get(parent.id)
+    if (parentActivation === undefined) return
+    if (parentActivation.inbox.closing !== undefined) {
+      throw new SubagentError(
+        `subagent parent "${parent.id}" is being disposed; the child was not established`,
+        'ACTIVATION_CLOSING',
+      )
+    }
+    parentActivation.ownedChildren.add(childId)
+  }
+
+  /** Remove one child from its live owner's set and let that owner re-check settlement. */
+  private releaseOwnership(childId: SessionId): void {
+    for (const candidate of this.resident.values()) {
+      if (candidate.ownedChildren.delete(childId)) this.wake(candidate)
+    }
+  }
+
+  /** Let a settlement watcher re-check residency after relevant state changes. */
+  private wake(activation: Activation): void {
+    activation.poke.resolve()
+    activation.poke = Promise.withResolvers<void>()
+  }
+
+  /** Follow one Activation to natural settlement. */
+  private watchSettlement(activation: Activation): void {
+    void (async () => {
+      while (true) {
+        const idleObservation = activation.poke
+        await activation.handle.agent.whenIdle()
+        if (activation.inbox.closing !== undefined) return
+        const readiness = await this.locks.run(activation.childId, () => Promise.resolve(
+          this.settlementState(activation, idleObservation),
+        ))
+        if (readiness === 'closed') return
+        if (readiness === 'retry') continue
+        if (readiness === 'wait') {
+          await idleObservation.promise
+          continue
+        }
+
+        const finalSeq = activation.handle.agent.session.seq
+        await this.flushFinalState(activation)
+        const attempt = await this.locks.run<SettlementAttempt>(activation.childId, () => {
+          const state = this.settlementState(activation, idleObservation)
+          if (state !== 'ready') return Promise.resolve(state)
+          if (activation.handle.agent.session.seq !== finalSeq) {
+            return Promise.resolve('retry')
+          }
+          // The task starts synchronously, so idle ownership and Inbox closure share one turn.
+          let done!: Promise<void>
+          try {
+            void activation.handle.agent.runMaintenance(() => {
+              done = this.dispose(activation, true)
+              return Promise.resolve()
+            })
+          } catch {
+            // Another activity won the idle phase after the preceding observation.
+            return Promise.resolve('retry')
+          }
+          return Promise.resolve({ done })
+        })
+
+        if (attempt === 'closed') return
+        if (attempt === 'retry') continue
+        if (attempt === 'wait') {
+          await idleObservation.promise
+          continue
+        }
+        try {
+          await attempt.done
+        } catch (error: unknown) {
+          this.ctx.logger.warn(
+            `subagent "${activation.childId}" activation teardown failed: ${errorChain(error)}`,
+          )
+        }
+        return
+      }
+    })()
+  }
+
+  /** Classify one Inbox and owned-child observation without reading Agent execution state. */
+  private settlementState(
+    activation: Activation,
+    observation: PromiseWithResolvers<void>,
+  ): SettlementState {
+    if (activation.inbox.closing !== undefined) return 'closed'
+    if (activation.poke !== observation) return 'retry'
+    if (activation.inbox.hasPending || activation.ownedChildren.size > 0) return 'wait'
+    return 'ready'
+  }
+
+  /** Propagate stop synchronously, then finish the child-first release. */
+  private async finishDisposal(activation: Activation, finalStateFlushed: boolean): Promise<void> {
+    this.wake(activation)
+    const { childId } = activation
+    const failures: SubagentError[] = []
+    if (finalStateFlushed) {
+      try {
+        activation.observer.capture(activation.handle.agent)
+      } catch (error: unknown) {
+        failures.push(new SubagentError(
+          `subagent "${childId}" activation teardown failed: ${errorChain(error)}`,
+          'ACTIVATION_TEARDOWN_FAILED',
+          { cause: error },
+        ))
+      }
+    } else {
+      activation.handle.agent.cancel({ kind: 'parent' })
+      const idle = activation.handle.agent.whenIdle()
+      const children = [...activation.ownedChildren]
+        .map(child => this.resident.get(child))
+        .filter((child): child is Activation => child !== undefined)
+      const childDisposals = children.map(child => this.dispose(child))
+      try {
+        const childFailures = await Promise.all(childDisposals.map(async (disposal) => {
+          try {
+            await disposal
+            return undefined
+          } catch (error: unknown) {
+            return error
+          }
+        }))
+        const reasons = childFailures.filter(reason => reason !== undefined)
+        if (reasons.length > 0) {
+          failures.push(new SubagentError(
+            `subagent "${childId}" child teardown failed: ${reasons.map(reason => errorChain(reason)).join('; ')}`,
+            'ACTIVATION_TEARDOWN_FAILED',
+          ))
+        }
+        await idle
+        await this.flushFinalState(activation)
+        activation.observer.capture(activation.handle.agent)
+      } catch (error: unknown) {
+        failures.push(new SubagentError(
+          `subagent "${childId}" activation teardown failed: ${errorChain(error)}`,
+          'ACTIVATION_TEARDOWN_FAILED',
+          { cause: error },
+        ))
+      }
+    }
+    try {
+      await activation.handle.dispose()
+    } catch (error: unknown) {
+      failures.push(new SubagentError(
+        `subagent "${childId}" activation handle disposal failed: ${errorChain(error)}`,
+        'ACTIVATION_TEARDOWN_FAILED',
+        { cause: error },
+      ))
+    }
+
+    let failure: SubagentError | undefined
+    if (failures.length === 1) {
+      failure = failures[0]
+    } else if (failures.length > 1) {
+      failure = new SubagentError(
+        `subagent "${childId}" activation teardown failed at ${failures.length} boundaries: `
+        + failures.map(item => errorChain(item)).join('; '),
+        'ACTIVATION_TEARDOWN_FAILED',
+        { cause: new AggregateError(failures) },
+      )
+    }
+    this.resident.delete(childId)
+    this.notifySettlement(activation, activation.observer.terminal(failure))
+    this.releaseOwnership(childId)
+    activation.observer.settle(failure)
+    if (failure !== undefined) throw failure
+  }
+
+  /** Tell the durable direct parent how this Activation ended. */
+  private notifySettlement(activation: Activation, terminal: ActivationTerminal): void {
+    if (!activation.announced) return
+    try {
+      const parent = this.ctx.agents.get(activation.parentSession)
+      if (parent === undefined) return
+      const message = createSettlementMessage(activation.childId, terminal)
+      if (this.closingTeardownFor(parent) !== undefined) {
+        parent.inject(message)
+        return
+      }
+      this.sendWaking(parent, message, parent.status === 'idle' ? 'queue' : 'steer')
+    } catch (error: unknown) {
+      this.ctx.logger.warn(
+        `subagent "${activation.childId}" settlement notice was not delivered to its parent: `
+        + errorChain(error),
+      )
+    }
+  }
+
+  /** Request a best-effort final session flush before closing natural-settlement admission. */
+  private async flushFinalState(activation: Activation): Promise<void> {
+    const child = activation.handle.agent
+    try {
+      await child.ctx.sessions.flush(child.session)
+    } catch (error: unknown) {
+      this.ctx.logger.warn(
+        `subagent "${activation.childId}" best-effort final session flush failed; `
+        + `the persisted state may be unavailable or stale on resume: ${errorChain(error)}`,
+      )
+    }
+  }
+}

+ 154 - 0
packages/subagent/subagent/src/continuation-messages.ts

@@ -0,0 +1,154 @@
+/**
+ * Model-visible messages owned by continuable-subagent orchestration.
+ *
+ * @module @deepseek-ai/dsh-subagent/continuation-messages
+ */
+
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import { boundContextSummary, createUserMessage } from '@deepseek-ai/dsh-llm'
+import type { ContentBlock } from '@deepseek-ai/dsh-llm'
+import type { SessionId } from '@deepseek-ai/dsh-session'
+import type { ActivationTerminal } from './lifecycle.ts'
+import type { SubagentResult } from './types.ts'
+
+/** Durable attribution for one model-authored message between adjacent Agents. */
+export interface AgentMessageSource {
+  readonly kind: 'agent-message'
+  /** A message another agent addressed to this one (`relay` context form). */
+  readonly form: 'relay'
+  /** Session id of the Agent whose tool call produced the message. */
+  readonly senderSessionId: SessionId
+}
+
+/**
+ * Durable attribution for the runtime's own account of a continuable child
+ * settling. Deliberately a different kind from
+ * {@link AgentMessageSource}: an Agent message is content the sender chose,
+ * while this message is the manager stating what became of the child, and a
+ * transcript that merged them would credit the child with words it never wrote.
+ */
+export interface SubagentSettledMessageSource {
+  readonly kind: 'subagent-settled'
+  /** A runtime account shown without expanding the row (`notice` context form). */
+  readonly form: 'notice'
+  /** One-line account of how the child ended. */
+  readonly summary: string
+  /** Session id of the child that settled. */
+  readonly senderSessionId: SessionId
+}
+
+declare module '@deepseek-ai/dsh-llm' {
+  interface MessageSourceMap {
+    'agent-message': AgentMessageSource
+    'subagent-settled': SubagentSettledMessageSource
+  }
+}
+
+/** Build durable attribution for one adjacent-Agent message. */
+function agentMessageSource(sender: Agent): AgentMessageSource {
+  return {
+    kind: 'agent-message',
+    form: 'relay',
+    senderSessionId: sender.id,
+  }
+}
+
+/**
+ * Build the model-visible and durable representation of one adjacent-Agent message.
+ * @param sender - exact live Agent that authored the message.
+ * @param content - model-visible message blocks supplied by the sender.
+ * @returns the durable user-message representation delivered to the recipient.
+ */
+export function createAgentMessage(
+  sender: Agent,
+  content: ContentBlock[],
+): ReturnType<typeof createUserMessage> {
+  return createUserMessage({
+    content: [
+      { type: 'text' as const, text: `Agent ${sender.id} sent a message: ` },
+      ...content,
+    ],
+    source: agentMessageSource(sender),
+  })
+}
+
+/**
+ * Append adjacent-Agent return guidance to a continuable child's initial task.
+ * @param parentId - durable parent session id named in the guidance.
+ * @param prompt - initial model-visible task blocks.
+ * @returns task blocks followed by the continuable return guidance.
+ */
+export function withContinuableReturnGuidance(
+  parentId: SessionId,
+  prompt: ContentBlock[],
+): ContentBlock[] {
+  const encodedParentId = JSON.stringify(parentId)
+  return [
+    ...prompt,
+    {
+      type: 'text',
+      text: `Your parent agent id is ${encodedParentId}. Before you finish, send your result to that agent with `
+        + `send_message({ agent_id: ${encodedParentId}, message: "<self-contained result>" }). The parent shares `
+        + 'your workspace but does not automatically receive your transcript, tool output, or reasoning. Send '
+        + 'earlier messages as well when a finding changes what the parent should do next; sending a message '
+        + 'does not end your turn.',
+    },
+  ]
+}
+
+/**
+ * One line telling a parent that a background child is finished and why, in
+ * the parent's own task vocabulary.
+ * @param childId - the durable child the parent knows by id.
+ * @param stopReason - how the child's last ordinary turn ended.
+ * @returns the model-facing opening line of the settlement notice.
+ */
+function settlementSummary(childId: SessionId, stopReason: SubagentResult['stopReason']): string {
+  const subject = `Background subagent ${childId}`
+  switch (stopReason) {
+    case 'completed':
+      return `${subject} finished and will do no further work unless you send it more.`
+    case 'aborted':
+      return `${subject} was stopped before it finished.`
+    case 'max-tokens':
+      return `${subject} ran out of room before it finished.`
+    // A pre-step rejection — a hook deny, a policy plugin — discarded input
+    // the child had claimed, so the parent must not treat the task as done.
+    case 'refusal':
+      return `${subject} declined the task.`
+    case 'error':
+      return `${subject} failed before it finished.`
+    /* v8 ignore next 4 -- `SubagentResult['stopReason']` is merge-extensible, so this arm
+     * needs a backend that adds a variant; an unnameable ending is reported as unfinished
+     * rather than silently as success. */
+    default:
+      return `${subject} ended abnormally (${String(stopReason)}) before it finished.`
+  }
+}
+
+/**
+ * Build the runtime-owned settlement notice delivered to a child's parent.
+ * @param childId - durable child session id named in the notice.
+ * @param terminal - recorded terminal state for the settled Activation.
+ * @returns the durable user-message representation delivered to the parent.
+ */
+export function createSettlementMessage(
+  childId: SessionId,
+  terminal: ActivationTerminal,
+): ReturnType<typeof createUserMessage> {
+  const summary = settlementSummary(childId, terminal.stopReason)
+  return createUserMessage({
+    content: [
+      { type: 'text' as const, text: summary },
+      ...terminal.output === undefined
+        ? [{ type: 'text' as const, text: 'It left no closing message.' }]
+        : [{ type: 'text' as const, text: 'Its closing message:' }, ...terminal.output],
+    ],
+    source: {
+      kind: 'subagent-settled' as const,
+      form: 'notice' as const,
+      summary: boundContextSummary(summary),
+      senderSessionId: childId,
+    },
+  })
+}

文件差异内容过多而无法显示
+ 91 - 759
packages/subagent/subagent/src/continuation.ts


+ 2 - 0
packages/subagent/subagent/src/control-types.ts

@@ -102,6 +102,8 @@ export interface SubagentPromptRequest {
   readonly childSessionId: SessionId
   readonly childSessionId: SessionId
   /** Required discriminator retained from the browser control address. */
   /** Required discriminator retained from the browser control address. */
   readonly mode: 'continuable'
   readonly mode: 'continuable'
+  /** Whether this message queues a later turn or targets the nearest step. */
+  readonly delivery: 'queue' | 'steer'
   /**
   /**
    * Browser prompt parts delivered as the child's user message. The Host
    * Browser prompt parts delivered as the child's user message. The Host
    * admits and persists image parts before delivery, so the wire never
    * admits and persists image parts before delivery, so the wire never

+ 1 - 0
packages/subagent/subagent/src/control.ts

@@ -21,6 +21,7 @@ const CONTROL_ID_SCHEMAS = {
     parentSessionId: SESSION_ID_SCHEMA,
     parentSessionId: SESSION_ID_SCHEMA,
     childSessionId: SESSION_ID_SCHEMA,
     childSessionId: SESSION_ID_SCHEMA,
     mode: z.literal('continuable'),
     mode: z.literal('continuable'),
+    delivery: z.enum(['queue', 'steer']),
   }),
   }),
   'subagent.interrupt': z.object({
   'subagent.interrupt': z.object({
     parentSessionId: SESSION_ID_SCHEMA,
     parentSessionId: SESSION_ID_SCHEMA,

+ 70 - 0
packages/subagent/subagent/src/inbox.ts

@@ -0,0 +1,70 @@
+/**
+ * Activation-local admission around one continuable subagent's Agent inbox.
+ *
+ * @module @deepseek-ai/dsh-subagent/inbox
+ */
+
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import type { UserMessage } from '@deepseek-ai/dsh-session'
+import type { SubagentPromptRequest } from './control-types.ts'
+import { SubagentError } from './error.ts'
+
+/** One Agent inbox destination, as the wire request selects it. */
+export type SubagentDelivery = SubagentPromptRequest['delivery']
+
+/** Delegate Queue and Steer to one live Agent until its Activation starts closing. */
+export class SubagentInbox {
+  private closingPromise: Promise<void> | undefined
+
+  /**
+   * Wrap one live continuable Agent.
+   * @param agent - the Agent whose inbox receives accepted deliveries.
+   */
+  constructor(private readonly agent: Agent) {}
+
+  /**
+   * Read the Activation's close transaction.
+   * @returns the memoized transaction, or `undefined` while delivery remains open.
+   */
+  get closing(): Promise<void> | undefined {
+    return this.closingPromise
+  }
+
+  /**
+   * Read whether the underlying Agent still has accepted work to claim.
+   * @returns whether either Agent inbox destination is non-empty.
+   */
+  get hasPending(): boolean {
+    return this.agent.inbox.hasPending
+  }
+
+  /**
+   * Submit through the Agent only while its Activation remains resident.
+   * @param message - the accepted input to submit.
+   * @param delivery - whether to queue a distinct turn or steer the nearest step.
+   */
+  deliver(message: UserMessage, delivery: SubagentDelivery): void {
+    if (this.closingPromise !== undefined) {
+      throw new SubagentError(
+        `subagent "${this.agent.id}" activation is being disposed; the message was not accepted`,
+        'ACTIVATION_CLOSING',
+      )
+    }
+    if (delivery === 'steer') this.agent.steer(message)
+    else this.agent.followup(message)
+  }
+
+  /**
+   * Close delivery synchronously and share one asynchronous release.
+   * @param release - the one release operation to start after closing admission.
+   * @returns the memoized release transaction.
+   */
+  close(release: () => Promise<void>): Promise<void> {
+    const existing = this.closingPromise
+    if (existing !== undefined) return existing
+    const completion = Promise.withResolvers<void>()
+    this.closingPromise = completion.promise
+    void release().then(completion.resolve, completion.reject)
+    return completion.promise
+  }
+}

+ 18 - 21
packages/subagent/subagent/src/index.ts

@@ -52,12 +52,16 @@ import type {
 import type {
 import type {
   ContinuableCreateRequest,
   ContinuableCreateRequest,
   ContinuableCreateSpec,
   ContinuableCreateSpec,
+  ContinuableStart,
+  ContinuableStartSpec,
   ResolvedSubagentStartRequest,
   ResolvedSubagentStartRequest,
   SubagentCapabilities,
   SubagentCapabilities,
+  SubagentInterruptAuthority,
   SubagentProvider,
   SubagentProvider,
   SubagentRun,
   SubagentRun,
   SubagentRunEndInfo,
   SubagentRunEndInfo,
   SubagentRunInfo,
   SubagentRunInfo,
+  SubagentSendMessageOptions,
   SubagentStartRequest,
   SubagentStartRequest,
 } from './types.ts'
 } from './types.ts'
 import { SubagentError } from './error.ts'
 import { SubagentError } from './error.ts'
@@ -65,17 +69,12 @@ import { assertSubagentMaxDepth } from './depth.ts'
 import { createActivationObserver, createLifecycleEmitter, observeRun } from './lifecycle.ts'
 import { createActivationObserver, createLifecycleEmitter, observeRun } from './lifecycle.ts'
 import type { ActivationObserver, LifecycleEmitter } from './lifecycle.ts'
 import type { ActivationObserver, LifecycleEmitter } from './lifecycle.ts'
 import SubagentContinuationManager from './continuation.ts'
 import SubagentContinuationManager from './continuation.ts'
-import type {
-  ContinuableStart,
-  ContinuableStartSpec,
-  SubagentInterruptAuthority,
-  SubagentSendMessageOptions,
-} from './continuation.ts'
+import type { SubagentDelivery } from './inbox.ts'
 import { listChildren as listSubagentChildren, listDescendants as listSubagentDescendants } from './list-children.ts'
 import { listChildren as listSubagentChildren, listDescendants as listSubagentDescendants } from './list-children.ts'
 import type { SubagentDescendantListEntry, SubagentListEntry } from './list-children.ts'
 import type { SubagentDescendantListEntry, SubagentListEntry } from './list-children.ts'
 import { snapshotSubagentDescriptor } from './descriptor.ts'
 import { snapshotSubagentDescriptor } from './descriptor.ts'
 import { subagentIdentityProjectionDefinition, subagentTimingProjectionDefinition } from './projection.ts'
 import { subagentIdentityProjectionDefinition, subagentTimingProjectionDefinition } from './projection.ts'
-import { deliverSubagentPrompt, type HostPromptDeliveryMode } from './internal.ts'
+import { deliverSubagentPrompt } from './internal.ts'
 
 
 export * from './out-of-process.ts'
 export * from './out-of-process.ts'
 export { AssistantOutputFold, finalAssistantOutput } from './assistant-output.ts'
 export { AssistantOutputFold, finalAssistantOutput } from './assistant-output.ts'
@@ -83,11 +82,15 @@ export { SubagentRunId } from './types.ts'
 export type {
 export type {
   ContinuableCreateRequest,
   ContinuableCreateRequest,
   ContinuableCreateSpec,
   ContinuableCreateSpec,
+  ContinuableStart,
+  ContinuableStartSpec,
   ResolvedSubagentStartRequest,
   ResolvedSubagentStartRequest,
   SubagentCapabilities,
   SubagentCapabilities,
+  SubagentInterruptAuthority,
   SubagentProvider,
   SubagentProvider,
   SubagentResult,
   SubagentResult,
   SubagentRun,
   SubagentRun,
+  SubagentSendMessageOptions,
   SubagentStartRequest,
   SubagentStartRequest,
   SubagentStopReason,
   SubagentStopReason,
   SubagentStopReasonMap,
   SubagentStopReasonMap,
@@ -119,14 +122,7 @@ export {
   SubagentDepthError,
   SubagentDepthError,
 } from './child-agent.ts'
 } from './child-agent.ts'
 export type { ChildComposition, DelegatedPolicyOverrides } from './child-agent.ts'
 export type { ChildComposition, DelegatedPolicyOverrides } from './child-agent.ts'
-export type {
-  AgentMessageSource,
-  ContinuableStart,
-  ContinuableStartSpec,
-  SubagentInterruptAuthority,
-  SubagentSendMessageOptions,
-  SubagentSettledMessageSource,
-} from './continuation.ts'
+export type { AgentMessageSource, SubagentSettledMessageSource } from './continuation-messages.ts'
 export type * from './control-types.ts'
 export type * from './control-types.ts'
 export type { SubagentDescendantListEntry } from './list-children.ts'
 export type { SubagentDescendantListEntry } from './list-children.ts'
 export type { SubagentRunEndInfo, SubagentRunInfo } from './types.ts'
 export type { SubagentRunEndInfo, SubagentRunInfo } from './types.ts'
@@ -271,7 +267,7 @@ export class SubagentRuntime extends TypertRemoteService {
     content: ContentBlock[],
     content: ContentBlock[],
     source: MessageSource,
     source: MessageSource,
     signal: AbortSignal,
     signal: AbortSignal,
-    delivery: HostPromptDeliveryMode,
+    delivery: SubagentDelivery,
   ): Promise<MessageId> {
   ): Promise<MessageId> {
     return delivery === 'steer'
     return delivery === 'steer'
       ? this.requireContinuations().steerPrompt(parent, childId, content, source, signal)
       ? this.requireContinuations().steerPrompt(parent, childId, content, source, signal)
@@ -397,11 +393,12 @@ export class SubagentRuntime extends TypertRemoteService {
    * Deliver one browser-authored message to a continuable child through the
    * Deliver one browser-authored message to a continuable child through the
    * exact live direct parent, retaining the caller-minted request identity and
    * exact live direct parent, retaining the caller-minted request identity and
    * validated browser zone on the accepted message. Success identifies the
    * validated browser zone on the accepted message. Success identifies the
-   * message the child's FIFO inbox accepted; later execution is independent of
-   * this call.
+   * message the child's inbox accepted; later execution is independent of this
+   * call. Queue delivery targets a later turn; steer delivery targets the
+   * nearest step and retains the Agent loop's best-effort fallback semantics.
    * Image parts are admitted and persisted through the attachment store
    * Image parts are admitted and persisted through the attachment store
    * before delivery, and the child's model must accept image input.
    * before delivery, and the child's model must accept image input.
-   * @param request - durable address, minted identity, content, and optional browser zone.
+   * @param request - durable address, delivery, minted identity, content, and optional browser zone.
    * @param signal - carrier cancellation, owning the call until inbox acceptance.
    * @param signal - carrier cancellation, owning the call until inbox acceptance.
    * @returns the accepted message's inbox identity.
    * @returns the accepted message's inbox identity.
    * @throws {RemoteError} `gateway/bad-request`, `subagent/attachment-invalid`,
    * @throws {RemoteError} `gateway/bad-request`, `subagent/attachment-invalid`,
@@ -411,7 +408,7 @@ export class SubagentRuntime extends TypertRemoteService {
    */
    */
   @Remote('prompt')
   @Remote('prompt')
   async prompt(request: SubagentPromptRequest, signal: AbortSignal): Promise<SubagentPromptReceipt> {
   async prompt(request: SubagentPromptRequest, signal: AbortSignal): Promise<SubagentPromptReceipt> {
-    const { parentSessionId, childSessionId, clientTimeZone } = request
+    const { parentSessionId, childSessionId, clientTimeZone, delivery } = request
     validateControlRequest('subagent.prompt', request)
     validateControlRequest('subagent.prompt', request)
     const canonicalTimeZone = clientTimeZone === undefined
     const canonicalTimeZone = clientTimeZone === undefined
       ? undefined
       ? undefined
@@ -454,7 +451,7 @@ export class SubagentRuntime extends TypertRemoteService {
           content,
           content,
           source,
           source,
           signal,
           signal,
-          'queue',
+          delivery,
         ),
         ),
       }
       }
     } catch (error: unknown) {
     } catch (error: unknown) {

+ 2 - 4
packages/subagent/subagent/src/internal.ts

@@ -9,6 +9,7 @@ import type { ContentBlock, MessageId, MessageSource } from '@deepseek-ai/dsh-ll
 import type { SessionId } from '@deepseek-ai/dsh-session'
 import type { SessionId } from '@deepseek-ai/dsh-session'
 import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
 import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
 import type SubagentRuntime from './index.ts'
 import type SubagentRuntime from './index.ts'
+import type { SubagentDelivery } from './inbox.ts'
 
 
 /** Process-stable identity carried only by the standard adjacent-Agent messaging tool. */
 /** Process-stable identity carried only by the standard adjacent-Agent messaging tool. */
 export const adjacentAgentSendMessageTool = Symbol.for('dsh.subagent.adjacentAgentSendMessageTool')
 export const adjacentAgentSendMessageTool = Symbol.for('dsh.subagent.adjacentAgentSendMessageTool')
@@ -40,9 +41,6 @@ export function isAdjacentAgentSendMessageTool(definition: ToolDefinition | unde
  */
  */
 export const deliverSubagentPrompt = Symbol.for('dsh.subagent.deliverPrompt')
 export const deliverSubagentPrompt = Symbol.for('dsh.subagent.deliverPrompt')
 
 
-/** Scheduling mode for one host-only direct-child prompt. */
-export type HostPromptDeliveryMode = 'queue' | 'steer'
-
 /** Runtime face required by the host-only prompt adapters. */
 /** Runtime face required by the host-only prompt adapters. */
 export interface HostPromptDeliverer {
 export interface HostPromptDeliverer {
   [deliverSubagentPrompt](
   [deliverSubagentPrompt](
@@ -51,7 +49,7 @@ export interface HostPromptDeliverer {
     content: ContentBlock[],
     content: ContentBlock[],
     source: MessageSource,
     source: MessageSource,
     signal: AbortSignal,
     signal: AbortSignal,
-    delivery: HostPromptDeliveryMode,
+    delivery: SubagentDelivery,
   ): Promise<MessageId>
   ): Promise<MessageId>
 }
 }
 
 

+ 45 - 1
packages/subagent/subagent/src/types.ts

@@ -11,7 +11,7 @@
 
 
 import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent'
 import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent'
 import type { Branded } from '@deepseek-ai/dsh-brand'
 import type { Branded } from '@deepseek-ai/dsh-brand'
-import type { ContentBlock } from '@deepseek-ai/dsh-llm'
+import type { ContentBlock, MessageId } from '@deepseek-ai/dsh-llm'
 import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
 import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
 import type { ObjectJsonSchema, ToolRestriction } from '@deepseek-ai/dsh-tools'
 import type { ObjectJsonSchema, ToolRestriction } from '@deepseek-ai/dsh-tools'
 import type { SubagentDescriptorData } from './descriptor.ts'
 import type { SubagentDescriptorData } from './descriptor.ts'
@@ -28,6 +28,50 @@ export function SubagentRunId(id: string): SubagentRunId {
   return id as SubagentRunId
   return id as SubagentRunId
 }
 }
 
 
+/** What a caller asks for when starting a continuable background child. */
+export interface ContinuableStartSpec {
+  /** The `ctx.subagents` provider whose continuable-creation capability establishes the child. */
+  readonly provider: string
+  /** The initial delegation's short `description`, persisted as the child's creation label. */
+  readonly label: string
+  /**
+   * Optional caller-reserved child identity. Omission preserves the manager's
+   * UUID allocation; supplying one lets a durable parent record provisioning
+   * before child materialization without a second identity handshake.
+   */
+  readonly childId?: SessionId
+  /**
+   * The delegation request. The manager reserves the stable child id, resolves
+   * the durable descriptor, and composes the child itself.
+   */
+  readonly request: Omit<SubagentStartRequest, 'label' | 'signal' | 'outputSchema'>
+  /** Caller cancellation, owning the operation only until inbox acceptance. */
+  readonly signal: AbortSignal
+}
+
+/** Identities returned once a continuable child accepted its initial prompt. */
+export interface ContinuableStart {
+  /** The durable child session id, stable across activations. */
+  readonly childId: SessionId
+  /** The accepted initial prompt's inbox message id. */
+  readonly messageId: MessageId
+}
+
+/**
+ * Authority under which one interrupt request is admitted. `user` carries the
+ * durable direct-parent address a human client presented; `ancestor` carries
+ * the exact live Agent object whose recorded lineage must contain the caller.
+ */
+export type SubagentInterruptAuthority =
+  | { readonly kind: 'user'; readonly parentSessionId: SessionId }
+  | { readonly kind: 'ancestor'; readonly agent: Agent }
+
+/** Options for one model-authored message between adjacent Agents. */
+export interface SubagentSendMessageOptions {
+  /** Caller cancellation, owning the operation only until inbox acceptance. */
+  readonly signal: AbortSignal
+}
+
 /**
 /**
  * Observe-only identifying detail for a published subagent run, carried by
  * Observe-only identifying detail for a published subagent run, carried by
  * `subagent/start`. One-shot runs and continuable Activation epochs share this
  * `subagent/start`. One-shot runs and continuable Activation epochs share this

+ 30 - 0
packages/subagent/subagent/tests/continuation-internals.ts

@@ -0,0 +1,30 @@
+/** Package-private continuation owners used to place deterministic lifecycle races. */
+
+import type { Context } from '@deepseek-ai/cordis'
+import type { SessionId } from '@deepseek-ai/dsh-session'
+import type { Activation, ContinuableActivationRegistry } from '../src/continuation-activation.ts'
+import type SubagentContinuationManager from '../src/continuation.ts'
+
+/** Return the service's bound continuation manager. */
+export function continuationManager(ctx: Context): SubagentContinuationManager {
+  const manager = (ctx.subagents as unknown as {
+    continuations?: SubagentContinuationManager
+  }).continuations
+  if (manager === undefined) throw new Error('expected a bound continuation manager')
+  return manager
+}
+
+/** Return the manager's sole process-local Activation owner. */
+export function continuationActivations(ctx: Context): ContinuableActivationRegistry {
+  return (continuationManager(ctx) as unknown as {
+    activations: ContinuableActivationRegistry
+  }).activations
+}
+
+/** Remove only the registry entry, leaving its Agent live for collision coverage. */
+export function dropContinuationActivation(ctx: Context, childId: SessionId): void {
+  const registry = continuationActivations(ctx) as unknown as {
+    resident: Map<SessionId, Activation>
+  }
+  registry.resident.delete(childId)
+}

+ 474 - 115
packages/subagent/subagent/tests/continuation.spec.ts

@@ -22,9 +22,15 @@ import SubagentRuntime, {
   SUBAGENT_DESCRIPTOR_VERSION,
   SUBAGENT_DESCRIPTOR_VERSION,
 } from '../src/index.ts'
 } from '../src/index.ts'
 import type { SubagentRunEndInfo, SubagentRunInfo } from '../src/index.ts'
 import type { SubagentRunEndInfo, SubagentRunInfo } from '../src/index.ts'
+import type { SubagentPromptRequestId } from '../src/control-types.ts'
 import * as SubagentInvariant from '../src/invariant.ts'
 import * as SubagentInvariant from '../src/invariant.ts'
 import { TestSessionQuery } from './test-session-query.ts'
 import { TestSessionQuery } from './test-session-query.ts'
 import { loadStoredSession } from './persistence-helpers.ts'
 import { loadStoredSession } from './persistence-helpers.ts'
+import {
+  continuationActivations,
+  continuationManager,
+  dropContinuationActivation,
+} from './continuation-internals.ts'
 
 
 type Script = ConstructorParameters<typeof MockAdapter>[0]
 type Script = ConstructorParameters<typeof MockAdapter>[0]
 
 
@@ -125,6 +131,11 @@ function hasUserText(events: readonly SessionEvent[], text: string): boolean {
     && event.data.content.some(block => block.type === 'text' && block.text === text))
     && event.data.content.some(block => block.type === 'text' && block.text === text))
 }
 }
 
 
+function hasAssistantText(events: readonly SessionEvent[], text: string): boolean {
+  return events.some(event => event.type === 'assistant/message'
+    && event.data.message.content.some(block => block.type === 'text' && block.text === text))
+}
+
 /** Caller-supplied user message texts in log order (runtime-context snapshots excluded). */
 /** Caller-supplied user message texts in log order (runtime-context snapshots excluded). */
 function userTexts(events: readonly SessionEvent[]): string[] {
 function userTexts(events: readonly SessionEvent[]): string[] {
   return events.flatMap(event => event.type === 'user/message' && event.data.source.kind !== 'plugin'
   return events.flatMap(event => event.type === 'user/message' && event.data.source.kind !== 'plugin'
@@ -142,19 +153,24 @@ function queuePrompt(
   content: ContentBlock[],
   content: ContentBlock[],
   signal: AbortSignal = testSignal,
   signal: AbortSignal = testSignal,
 ) {
 ) {
-  const manager = (ctx.subagents as unknown as {
-    continuations?: {
-      queuePrompt(
-        parent: Agent,
-        childId: SessionId,
-        content: ContentBlock[],
-        source: { kind: 'user' },
-        signal: AbortSignal,
-      ): Promise<string>
-    }
-  }).continuations
-  if (manager === undefined) throw new Error('expected a bound continuation manager')
-  return manager.queuePrompt(parent, childId, content, { kind: 'user' }, signal)
+  return continuationManager(ctx).queuePrompt(parent, childId, content, { kind: 'user' }, signal)
+}
+
+function humanPrompt(
+  ctx: Context,
+  parent: Agent,
+  childId: SessionId,
+  text: string,
+  delivery: 'queue' | 'steer',
+) {
+  return ctx.subagents.prompt({
+    requestId: `request-${text}` as SubagentPromptRequestId,
+    parentSessionId: parent.id,
+    childSessionId: childId,
+    mode: 'continuable',
+    delivery,
+    content: message(text),
+  }, testSignal)
 }
 }
 
 
 /**
 /**
@@ -162,11 +178,7 @@ function queuePrompt(
  * adding the irreversible operation to the public service contract.
  * adding the irreversible operation to the public service contract.
  */
  */
 function drainManager(ctx: Context): Promise<void> {
 function drainManager(ctx: Context): Promise<void> {
-  const manager = (ctx.subagents as unknown as {
-    continuations?: { drain(): Promise<void> }
-  }).continuations
-  if (manager === undefined) throw new Error('expected a bound continuation manager')
-  return manager.drain()
+  return continuationManager(ctx).drain()
 }
 }
 
 
 /** Wait until a child's Activation is gone, i.e. its handle finished disposal. */
 /** Wait until a child's Activation is gone, i.e. its handle finished disposal. */
@@ -176,6 +188,46 @@ async function waitNoActivation(ctx: Context, childId: SessionId): Promise<void>
   }, { timeout: 5_000 })
   }, { timeout: 5_000 })
 }
 }
 
 
+/** Wait until the settlement watcher has checked the child's current idle state. */
+async function passSettlementCheck(ctx: Context, childId: SessionId): Promise<void> {
+  const manager = childLocks(ctx)
+  const release = Promise.withResolvers<undefined>()
+  const entered = Promise.withResolvers<undefined>()
+  const barrier = manager.locks.run(childId, async () => {
+    entered.resolve(undefined)
+    await release.promise
+  })
+  await entered.promise
+  release.resolve(undefined)
+  await barrier
+  await manager.locks.run(childId, () => Promise.resolve())
+}
+
+/** The Activation registry's package-private lock, which orders every child decision. */
+function childLocks(ctx: Context) {
+  return continuationActivations(ctx)
+}
+
+/**
+ * Occupy one child's lock so a settlement watcher that already observed
+ * quiescence waits behind the caller, which is the window where later Agent
+ * activity or Inbox changes race disposal.
+ * @returns the release callback and the held lock's completion.
+ */
+async function holdChildLock(
+  ctx: Context,
+  childId: SessionId,
+): Promise<{ release: () => void; held: Promise<void> }> {
+  const entered = Promise.withResolvers<undefined>()
+  const release = Promise.withResolvers<undefined>()
+  const held = childLocks(ctx).locks.run(childId, async () => {
+    entered.resolve(undefined)
+    await release.promise
+  })
+  await entered.promise
+  return { release: () => { release.resolve(undefined) }, held }
+}
+
 /**
 /**
  * Keep the top-level test parent out of a scripted model corpus. Every child
  * Keep the top-level test parent out of a scripted model corpus. Every child
  * settlement wakes its parent, so a suite that scripts only child responses
  * settlement wakes its parent, so a suite that scripts only child responses
@@ -917,6 +969,46 @@ describe('direct-child Queue residency routing', () => {
   })
   })
 })
 })
 
 
+describe('continuable human steering delivery', () => {
+  it('places resident steering in nextStep with its durable identity and source', async () => {
+    const release = Promise.withResolvers<undefined>()
+    const adapter = new GatedAdapter([
+      { chunks: textResponse('first'), gate: release.promise },
+      { chunks: textResponse('steered') },
+    ])
+    const { ctx, parent } = await setupWith(adapter)
+    parkParent(ctx, parent)
+    const started = await ctx.subagents.startContinuable(startSpec(parent))
+    await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
+    const child = ctx.agents.get(started.childId)!
+
+    const receipt = await humanPrompt(ctx, parent, started.childId, 'resident steer', 'steer')
+    expect(child.inbox.nextStep).toContainEqual(expect.objectContaining({
+      id: receipt.messageId,
+      content: message('resident steer'),
+      source: { kind: 'user', rpcId: 'request-resident steer' },
+    }))
+
+    release.resolve(undefined)
+    await waitNoActivation(ctx, started.childId)
+  })
+
+  it('cold-resumes steering into nextStep instead of inventing another queue', async () => {
+    const { ctx, parent } = await setup([textResponse('first'), textResponse('steered')])
+    parkParent(ctx, parent)
+    const started = await ctx.subagents.startContinuable(startSpec(parent))
+    await waitNoActivation(ctx, started.childId)
+
+    const receipt = await humanPrompt(ctx, parent, started.childId, 'cold steer', 'steer')
+    await waitNoActivation(ctx, started.childId)
+    const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId)
+    expect(loaded.events.some(event => event.type === 'agent/inbox/spliced'
+      && event.data.target === 'next-step'
+      && event.data.inserted.some(message => message.id === receipt.messageId))).toBe(true)
+    expect(hasUserText(loaded.events, 'cold steer')).toBe(true)
+  })
+})
+
 describe('continuable child ownership', () => {
 describe('continuable child ownership', () => {
   it('keeps a parent Activation waiting until its child completes disposal', async () => {
   it('keeps a parent Activation waiting until its child completes disposal', async () => {
     const releaseGrandchild = Promise.withResolvers<undefined>()
     const releaseGrandchild = Promise.withResolvers<undefined>()
@@ -956,6 +1048,187 @@ describe('continuable child ownership', () => {
 })
 })
 
 
 describe('continuable durability and teardown', () => {
 describe('continuable durability and teardown', () => {
+  it('rechecks direct Agent inbox work accepted during the final flush', async () => {
+    const releaseFirstTurn = Promise.withResolvers<undefined>()
+    const adapter = new GatedAdapter([
+      { chunks: textResponse('first answer'), gate: releaseFirstTurn.promise },
+      { chunks: textResponse('late answer') },
+    ])
+    const { ctx, parent } = await setupWith(adapter)
+    parkParent(ctx, parent)
+    const flushing = Promise.withResolvers<undefined>()
+    const releaseFlush = Promise.withResolvers<undefined>()
+    let childFlushes = 0
+    ctx.on('session/flush', async (session) => {
+      if (session.header.parentSession === undefined) return
+      childFlushes++
+      if (childFlushes !== 1) return
+      flushing.resolve(undefined)
+      await releaseFlush.promise
+    })
+
+    const started = await ctx.subagents.startContinuable(startSpec(parent))
+    const child = ctx.agents.get(started.childId)!
+    const cancelSpy = vi.spyOn(child, 'cancel')
+    releaseFirstTurn.resolve(undefined)
+    await flushing.promise
+    expect(cancelSpy).not.toHaveBeenCalled()
+    child.followup(createUserMessage({ content: message('accepted during flush'), source: { kind: 'user' } }))
+    await vi.waitFor(() => {
+      expect(adapter.requests).toHaveLength(2)
+      expect(hasAssistantText(child.session.snapshotEvents(), 'late answer')).toBe(true)
+    })
+    await child.whenIdle()
+    expect(cancelSpy).not.toHaveBeenCalled()
+    releaseFlush.resolve(undefined)
+    await waitNoActivation(ctx, started.childId)
+    expect(childFlushes).toBe(2)
+    const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId)
+    expect(hasUserText(loaded.events, 'accepted during flush')).toBe(true)
+    expect(hasAssistantText(loaded.events, 'late answer')).toBe(true)
+  })
+
+  it('retries after Session-only work completes during the final flush', async () => {
+    const releaseFirstTurn = Promise.withResolvers<undefined>()
+    const adapter = new GatedAdapter([
+      { chunks: textResponse('answer'), gate: releaseFirstTurn.promise },
+    ])
+    const { ctx, parent } = await setupWith(adapter)
+    parkParent(ctx, parent)
+    const flushing = Promise.withResolvers<undefined>()
+    const releaseFlush = Promise.withResolvers<undefined>()
+    let childFlushes = 0
+    ctx.on('session/flush', async (session) => {
+      if (session.header.parentSession === undefined) return
+      childFlushes++
+      if (childFlushes !== 1) return
+      flushing.resolve(undefined)
+      await releaseFlush.promise
+    })
+
+    const started = await ctx.subagents.startContinuable(startSpec(parent))
+    const child = ctx.agents.get(started.childId)!
+    releaseFirstTurn.resolve(undefined)
+    await flushing.promise
+    child.session.append('user/message', createUserMessage({
+      content: message('detached result'),
+      source: { kind: 'user' },
+    }), { surfaceOp: 'append' })
+    releaseFlush.resolve(undefined)
+
+    await waitNoActivation(ctx, started.childId)
+    expect(childFlushes).toBe(2)
+    const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId)
+    expect(hasUserText(loaded.events, 'detached result')).toBe(true)
+  })
+
+  it('keeps a child acquired during the final flush before settling', async () => {
+    const releaseFirstTurn = Promise.withResolvers<undefined>()
+    const releaseGrandchild = Promise.withResolvers<undefined>()
+    const adapter = new GatedAdapter([
+      { chunks: textResponse('child answer'), gate: releaseFirstTurn.promise },
+      { chunks: textResponse('grandchild answer'), gate: releaseGrandchild.promise },
+    ])
+    const { ctx, parent } = await setupWith(adapter)
+    parkParent(ctx, parent)
+    const flushing = Promise.withResolvers<undefined>()
+    const releaseFlush = Promise.withResolvers<undefined>()
+    let heldFinalFlush = false
+    ctx.on('session/flush', async (session) => {
+      if (session.header.parentSession !== parent.id || heldFinalFlush) return
+      heldFinalFlush = true
+      flushing.resolve(undefined)
+      await releaseFlush.promise
+    })
+
+    const started = await ctx.subagents.startContinuable(startSpec(parent))
+    const child = ctx.agents.get(started.childId)!
+    releaseFirstTurn.resolve(undefined)
+    await flushing.promise
+    const grandchild = await ctx.subagents.startContinuable(startSpec(child))
+    await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) })
+
+    releaseFlush.resolve(undefined)
+    await passSettlementCheck(ctx, started.childId)
+    expect(ctx.agents.get(started.childId)).toBe(child)
+
+    releaseGrandchild.resolve(undefined)
+    await waitNoActivation(ctx, grandchild.childId)
+    await waitNoActivation(ctx, started.childId)
+  })
+
+  it('lets explicit disposal win while the natural final flush is pending', async () => {
+    const releaseFirstTurn = Promise.withResolvers<undefined>()
+    const adapter = new GatedAdapter([
+      { chunks: textResponse('answer'), gate: releaseFirstTurn.promise },
+    ])
+    const { ctx, parent } = await setupWith(adapter)
+    parkParent(ctx, parent)
+    const flushing = Promise.withResolvers<undefined>()
+    const releaseFlush = Promise.withResolvers<undefined>()
+    let heldFinalFlush = false
+    ctx.on('session/flush', async (session) => {
+      if (session.header.parentSession !== parent.id || heldFinalFlush) return
+      heldFinalFlush = true
+      flushing.resolve(undefined)
+      await releaseFlush.promise
+    })
+
+    const started = await ctx.subagents.startContinuable(startSpec(parent))
+    releaseFirstTurn.resolve(undefined)
+    await flushing.promise
+    const drained = drainManager(ctx)
+    await drained
+
+    releaseFlush.resolve(undefined)
+    await passSettlementCheck(ctx, started.childId)
+    expect(ctx.agents.get(started.childId)).toBeUndefined()
+  })
+
+  it('rechecks maintenance that claims the Agent during the final flush', async () => {
+    const releaseFirstTurn = Promise.withResolvers<undefined>()
+    const adapter = new GatedAdapter([{ chunks: textResponse('answer'), gate: releaseFirstTurn.promise }])
+    const { ctx, parent } = await setupWith(adapter)
+    parkParent(ctx, parent)
+    const flushing = Promise.withResolvers<undefined>()
+    const releaseFlush = Promise.withResolvers<undefined>()
+    let heldFinalFlush = false
+    ctx.on('session/flush', async (session) => {
+      if (session.header.parentSession === undefined || heldFinalFlush) return
+      heldFinalFlush = true
+      flushing.resolve(undefined)
+      await releaseFlush.promise
+    })
+
+    const started = await ctx.subagents.startContinuable(startSpec(parent))
+    const child = ctx.agents.get(started.childId)!
+    const cancelSpy = vi.spyOn(child, 'cancel')
+    releaseFirstTurn.resolve(undefined)
+    await flushing.promise
+    expect(cancelSpy).not.toHaveBeenCalled()
+    const releaseMaintenance = Promise.withResolvers<undefined>()
+    let maintenanceSignal: AbortSignal | undefined
+    const maintenance = child.runMaintenance(async (signal) => {
+      maintenanceSignal = signal
+      await releaseMaintenance.promise
+    })
+    const runMaintenance = child.runMaintenance.bind(child)
+    const settlementClaimAttempted = Promise.withResolvers<undefined>()
+    vi.spyOn(child, 'runMaintenance').mockImplementation((task) => {
+      settlementClaimAttempted.resolve(undefined)
+      return runMaintenance(task)
+    })
+
+    releaseFlush.resolve(undefined)
+    await settlementClaimAttempted.promise
+    expect(maintenanceSignal?.aborted).toBe(false)
+    expect(ctx.agents.get(started.childId)).toBe(child)
+
+    releaseMaintenance.resolve(undefined)
+    await maintenance
+    await waitNoActivation(ctx, started.childId)
+  })
+
   it('settles despite the persistence backend being disposed mid-run', async () => {
   it('settles despite the persistence backend being disposed mid-run', async () => {
     const releaseResponse = Promise.withResolvers<undefined>()
     const releaseResponse = Promise.withResolvers<undefined>()
     const adapter = new GatedAdapter([
     const adapter = new GatedAdapter([
@@ -1008,10 +1281,7 @@ describe('continuable durability and teardown', () => {
 
 
     const started = await ctx.subagents.startContinuable(startSpec(parent))
     const started = await ctx.subagents.startContinuable(startSpec(parent))
     await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
     await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
-    const manager = (ctx.subagents as unknown as {
-      continuations: { activations: Map<SessionId, { handle: { dispose: () => Promise<void> } }> }
-    }).continuations
-    const activation = manager.activations.get(started.childId)!
+    const activation = continuationActivations(ctx).get(started.childId)!
     const realDispose = activation.handle.dispose.bind(activation.handle)
     const realDispose = activation.handle.dispose.bind(activation.handle)
     activation.handle.dispose = async () => {
     activation.handle.dispose = async () => {
       await realDispose()
       await realDispose()
@@ -1198,10 +1468,7 @@ describe('continuable durability and teardown', () => {
     const { ctx, parent } = await setupWith(adapter)
     const { ctx, parent } = await setupWith(adapter)
     const target = await ctx.subagents.startContinuable(startSpec(parent))
     const target = await ctx.subagents.startContinuable(startSpec(parent))
     await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
     await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
-    const manager = (ctx.subagents as unknown as {
-      continuations: { activations: Map<SessionId, { handle: { dispose: () => Promise<void> } }> }
-    }).continuations
-    const activation = manager.activations.get(target.childId)!
+    const activation = continuationActivations(ctx).get(target.childId)!
     const realDispose = activation.handle.dispose.bind(activation.handle)
     const realDispose = activation.handle.dispose.bind(activation.handle)
     activation.handle.dispose = async () => {
     activation.handle.dispose = async () => {
       await realDispose()
       await realDispose()
@@ -1279,10 +1546,7 @@ describe('continuable durability and teardown', () => {
 
 
   it('awaits and rolls back an admitted materialization below a scoped root', async () => {
   it('awaits and rolls back an admitted materialization below a scoped root', async () => {
     const { ctx, parent } = await setup([])
     const { ctx, parent } = await setup([])
-    const manager = (ctx.subagents as unknown as {
-      continuations: { ownerCtx: Context }
-    }).continuations
-    const agents = manager.ownerCtx.agents
+    const agents = continuationActivations(ctx).ownerCtx.agents
     const create = agents.create.bind(agents)
     const create = agents.create.bind(agents)
     const published = Promise.withResolvers<SessionId>()
     const published = Promise.withResolvers<SessionId>()
     const releaseMaterialization = Promise.withResolvers<undefined>()
     const releaseMaterialization = Promise.withResolvers<undefined>()
@@ -1330,10 +1594,7 @@ describe('continuable durability and teardown', () => {
     const { ctx, parent } = await setupWith(adapter)
     const { ctx, parent } = await setupWith(adapter)
     const started = await ctx.subagents.startContinuable(startSpec(parent))
     const started = await ctx.subagents.startContinuable(startSpec(parent))
     await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
     await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
-    const manager = (ctx.subagents as unknown as {
-      continuations: { activations: Map<SessionId, { handle: { dispose: () => Promise<void> } }> }
-    }).continuations
-    const activation = manager.activations.get(started.childId)!
+    const activation = continuationActivations(ctx).get(started.childId)!
     const realDispose = activation.handle.dispose.bind(activation.handle)
     const realDispose = activation.handle.dispose.bind(activation.handle)
     activation.handle.dispose = async () => {
     activation.handle.dispose = async () => {
       await realDispose()
       await realDispose()
@@ -1456,10 +1717,7 @@ describe('continuable review regressions', () => {
     const started = await ctx.subagents.startContinuable(startSpec(originalParent.agent))
     const started = await ctx.subagents.startContinuable(startSpec(originalParent.agent))
     await waitNoActivation(ctx, started.childId)
     await waitNoActivation(ctx, started.childId)
 
 
-    const manager = (ctx.subagents as unknown as {
-      continuations: { ownerCtx: Context }
-    }).continuations
-    const ownerAgents = manager.ownerCtx.agents
+    const ownerAgents = continuationActivations(ctx).ownerCtx.agents
     const originalResume = ownerAgents.resume.bind(ownerAgents)
     const originalResume = ownerAgents.resume.bind(ownerAgents)
     const resumed = Promise.withResolvers<undefined>()
     const resumed = Promise.withResolvers<undefined>()
     const releaseResume = Promise.withResolvers<undefined>()
     const releaseResume = Promise.withResolvers<undefined>()
@@ -1496,19 +1754,13 @@ describe('continuable review regressions', () => {
     await replacement.dispose()
     await replacement.dispose()
   })
   })
 
 
-  it('clears the accepted reservation when Agent.followup throws', async () => {
+  it('accepts a later delivery after Agent.followup throws', async () => {
     const hold = Promise.withResolvers<undefined>()
     const hold = Promise.withResolvers<undefined>()
     const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: hold.promise }])
     const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: hold.promise }])
     const { ctx, parent } = await setupWith(adapter)
     const { ctx, parent } = await setupWith(adapter)
     const started = await ctx.subagents.startContinuable(startSpec(parent))
     const started = await ctx.subagents.startContinuable(startSpec(parent))
     await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
     await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
     const child = ctx.agents.get(started.childId)!
     const child = ctx.agents.get(started.childId)!
-    const manager = (ctx.subagents as unknown as {
-      continuations: {
-        activations: Map<SessionId, { accepted: Set<MessageId> }>
-      }
-    }).continuations
-    const activation = manager.activations.get(started.childId)!
     const realFollowup = child.followup.bind(child)
     const realFollowup = child.followup.bind(child)
     child.followup = () => {
     child.followup = () => {
       throw new Error('synthetic inbox failure')
       throw new Error('synthetic inbox failure')
@@ -1516,9 +1768,10 @@ describe('continuable review regressions', () => {
 
 
     await expect(queuePrompt(ctx, parent, started.childId, message('throws')))
     await expect(queuePrompt(ctx, parent, started.childId, message('throws')))
       .rejects.toThrow(/synthetic inbox failure/)
       .rejects.toThrow(/synthetic inbox failure/)
-    expect(activation.accepted.size).toBe(0)
 
 
     child.followup = realFollowup
     child.followup = realFollowup
+    const accepted = await queuePrompt(ctx, parent, started.childId, message('accepted later'))
+    expect(child.inbox.nextTurn.some(candidate => candidate.id === accepted)).toBe(true)
     const drained = drainManager(ctx)
     const drained = drainManager(ctx)
     hold.resolve(undefined)
     hold.resolve(undefined)
     await drained
     await drained
@@ -1652,11 +1905,8 @@ describe('continuable review regressions', () => {
     ctx.on('subagent/end', (info) => { ends.push(info) })
     ctx.on('subagent/end', (info) => { ends.push(info) })
 
 
     const started = await ctx.subagents.startContinuable(startSpec(parent))
     const started = await ctx.subagents.startContinuable(startSpec(parent))
-    const manager = (ctx.subagents as unknown as {
-      continuations: { activations: Map<SessionId, { handle: { dispose: () => Promise<void> } }> }
-    }).continuations
     const activation = await vi.waitFor(() => {
     const activation = await vi.waitFor(() => {
-      const found = manager.activations.get(started.childId)
+      const found = continuationActivations(ctx).get(started.childId)
       expect(found).toBeDefined()
       expect(found).toBeDefined()
       return found!
       return found!
     })
     })
@@ -1680,12 +1930,7 @@ describe('continuable review regressions', () => {
     ctx.on('subagent/end', info => void ends.push(info))
     ctx.on('subagent/end', info => void ends.push(info))
 
 
     const started = await ctx.subagents.startContinuable(startSpec(parent))
     const started = await ctx.subagents.startContinuable(startSpec(parent))
-    const manager = (ctx.subagents as unknown as {
-      continuations: {
-        activations: Map<SessionId, { observer: { capture: (child: Agent) => void } }>
-      }
-    }).continuations
-    const activation = manager.activations.get(started.childId)!
+    const activation = continuationActivations(ctx).get(started.childId)!
     activation.observer.capture = () => { throw new Error('capture failed') }
     activation.observer.capture = () => { throw new Error('capture failed') }
 
 
     const drained = drainManager(ctx)
     const drained = drainManager(ctx)
@@ -1695,20 +1940,30 @@ describe('continuable review regressions', () => {
     expect(ends[0]!.stopReason).toBe('error')
     expect(ends[0]!.stopReason).toBe('error')
   })
   })
 
 
+  it('releases a naturally settled Activation when terminal capture fails', async () => {
+    const hold = Promise.withResolvers<undefined>()
+    const adapter = new GatedAdapter([{ chunks: textResponse('answer'), gate: hold.promise }])
+    const { ctx, parent } = await setupWith(adapter)
+    const ends: SubagentRunEndInfo[] = []
+    ctx.on('subagent/end', info => void ends.push(info))
+
+    const started = await ctx.subagents.startContinuable(startSpec(parent))
+    continuationActivations(ctx).get(started.childId)!.observer.capture = () => {
+      throw new Error('capture failed')
+    }
+
+    hold.resolve(undefined)
+    await waitNoActivation(ctx, started.childId)
+    await vi.waitFor(() => { expect(ends).toHaveLength(1) })
+    expect(ends[0]!.stopReason).toBe('error')
+  })
+
   it('preserves independent pre-disposal and handle-disposal failures', async () => {
   it('preserves independent pre-disposal and handle-disposal failures', async () => {
     const hold = Promise.withResolvers<undefined>()
     const hold = Promise.withResolvers<undefined>()
     const adapter = new GatedAdapter([{ chunks: textResponse('answer'), gate: hold.promise }])
     const adapter = new GatedAdapter([{ chunks: textResponse('answer'), gate: hold.promise }])
     const { ctx, parent } = await setupWith(adapter)
     const { ctx, parent } = await setupWith(adapter)
     const started = await ctx.subagents.startContinuable(startSpec(parent))
     const started = await ctx.subagents.startContinuable(startSpec(parent))
-    const manager = (ctx.subagents as unknown as {
-      continuations: {
-        activations: Map<SessionId, {
-          handle: { dispose: () => Promise<void> }
-          observer: { capture: (child: Agent) => void }
-        }>
-      }
-    }).continuations
-    const activation = manager.activations.get(started.childId)!
+    const activation = continuationActivations(ctx).get(started.childId)!
     const realDispose = activation.handle.dispose.bind(activation.handle)
     const realDispose = activation.handle.dispose.bind(activation.handle)
     activation.observer.capture = () => { throw new Error('capture failed') }
     activation.observer.capture = () => { throw new Error('capture failed') }
     activation.handle.dispose = async () => {
     activation.handle.dispose = async () => {
@@ -1771,6 +2026,138 @@ describe('continuable review regressions', () => {
     expect(hasUserText(loaded.events, 'discarded')).toBe(false)
     expect(hasUserText(loaded.events, 'discarded')).toBe(false)
   })
   })
 
 
+  it('settles after removing the last message from an idle parked Inbox', async () => {
+    const release = Promise.withResolvers<undefined>()
+    const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: release.promise }])
+    const { ctx, parent } = await setupWith(adapter)
+    const started = await ctx.subagents.startContinuable(startSpec(parent))
+    await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
+    const child = ctx.agents.get(started.childId)!
+    const messageId = await queuePrompt(ctx, parent, started.childId, message('queued'))
+    ctx.subagents.interrupt(started.childId, { kind: 'user', parentSessionId: parent.id })
+    release.resolve(undefined)
+    await child.whenIdle()
+    await passSettlementCheck(ctx, started.childId)
+    expect(child.inbox.remove(messageId)).toBe(true)
+    await waitNoActivation(ctx, started.childId)
+  })
+
+  it('keeps a maintenance task that claimed the idle phase after whenIdle resolved', async () => {
+    const release = Promise.withResolvers<undefined>()
+    const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: release.promise }])
+    const { ctx, parent } = await setupWith(adapter)
+    const started = await ctx.subagents.startContinuable(startSpec(parent))
+    await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
+    const child = ctx.agents.get(started.childId)!
+    // Held while the child still runs, so the watcher observes idle and then
+    // waits here with its settlement decision already outstanding.
+    const lock = await holdChildLock(ctx, started.childId)
+    release.resolve(undefined)
+    await child.whenIdle()
+    const finishMaintenance = Promise.withResolvers<undefined>()
+    let maintenanceSignal: AbortSignal | undefined
+    const maintenance = child.runMaintenance(async (signal) => {
+      maintenanceSignal = signal
+      await finishMaintenance.promise
+    })
+    lock.release()
+    await lock.held
+    await passSettlementCheck(ctx, started.childId)
+    expect(maintenanceSignal?.aborted).toBe(false)
+    expect(ctx.agents.get(started.childId) !== undefined).toBe(true)
+    finishMaintenance.resolve(undefined)
+    await maintenance
+    await waitNoActivation(ctx, started.childId)
+  })
+
+  it('settles when maintenance finishes after losing the idle phase', async () => {
+    const release = Promise.withResolvers<undefined>()
+    const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: release.promise }])
+    const { ctx, parent } = await setupWith(adapter)
+    const started = await ctx.subagents.startContinuable(startSpec(parent))
+    await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
+    const child = ctx.agents.get(started.childId)!
+    const lock = await holdChildLock(ctx, started.childId)
+    release.resolve(undefined)
+    await child.whenIdle()
+    const finishMaintenance = Promise.withResolvers<undefined>()
+    const maintenance = child.runMaintenance(async () => { await finishMaintenance.promise })
+    lock.release()
+    // Let the queued settlement check observe maintenance, then finish it
+    // before that check's caller receives the false result.
+    queueMicrotask(() => {
+      queueMicrotask(() => { finishMaintenance.resolve(undefined) })
+    })
+    await lock.held
+    await maintenance
+    await waitNoActivation(ctx, started.childId)
+  })
+
+  it.each([
+    { label: 'plugin', source: { kind: 'plugin' as const, plugin: 'tool-jobs' } },
+    { label: 'non-plugin', source: { kind: 'team-message', teamId: 't-1' } as never },
+  ])('keeps an idle child resident while its Inbox holds $label injected context', async ({ source }) => {
+    const release = Promise.withResolvers<undefined>()
+    const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: release.promise }])
+    const { ctx, parent } = await setupWith(adapter)
+    const started = await ctx.subagents.startContinuable(startSpec(parent))
+    await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
+    const child = ctx.agents.get(started.childId)!
+    const lock = await holdChildLock(ctx, started.childId)
+    release.resolve(undefined)
+    await child.whenIdle()
+    const context = createUserMessage({ content: message('parked context'), source })
+    child.inject(context)
+    expect(child.inbox.nextStep).toHaveLength(1)
+    lock.release()
+    await lock.held
+    await passSettlementCheck(ctx, started.childId)
+    expect(ctx.agents.get(started.childId) !== undefined).toBe(true)
+    expect(child.inbox.remove(context.id)).toBe(true)
+    await waitNoActivation(ctx, started.childId)
+  })
+
+  it('keeps an idle child resident while plugin-sourced steering stays unclaimed', async () => {
+    const release = Promise.withResolvers<undefined>()
+    const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: release.promise }])
+    const { ctx, parent } = await setupWith(adapter)
+    const started = await ctx.subagents.startContinuable(startSpec(parent))
+    await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
+    const child = ctx.agents.get(started.childId)!
+    // A cordis-host-runner failure report: `steer()` from a plugin still wakes
+    // a driver, so residency must survive until that turn claims the message.
+    const steered = createUserMessage({
+      content: message('Cordis Host handler failed'),
+      source: { kind: 'plugin', plugin: 'cordis-host-runner' },
+    })
+    child.steer(steered)
+    ctx.subagents.interrupt(started.childId, { kind: 'user', parentSessionId: parent.id })
+    release.resolve(undefined)
+    await child.whenIdle()
+    await passSettlementCheck(ctx, started.childId)
+    expect(ctx.agents.get(started.childId) !== undefined).toBe(true)
+    expect(child.inbox.remove(steered.id)).toBe(true)
+    await waitNoActivation(ctx, started.childId)
+  })
+
+  it('keeps an idle child resident while an interrupted turn leaves human steering parked', async () => {
+    const release = Promise.withResolvers<undefined>()
+    const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: release.promise }])
+    const { ctx, parent } = await setupWith(adapter)
+    const started = await ctx.subagents.startContinuable(startSpec(parent))
+    await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
+    const child = ctx.agents.get(started.childId)!
+    await humanPrompt(ctx, parent, started.childId, 'steered', 'steer')
+    ctx.subagents.interrupt(started.childId, { kind: 'user', parentSessionId: parent.id })
+    release.resolve(undefined)
+    await child.whenIdle()
+    const parked = child.inbox.nextStep[0]!
+    await passSettlementCheck(ctx, started.childId)
+    expect(ctx.agents.get(started.childId) !== undefined).toBe(true)
+    expect(child.inbox.remove(parked.id)).toBe(true)
+    await waitNoActivation(ctx, started.childId)
+  })
+
   it('settles after a delivery discarded inside its own admission window', async () => {
   it('settles after a delivery discarded inside its own admission window', async () => {
     const releaseFirst = Promise.withResolvers<undefined>()
     const releaseFirst = Promise.withResolvers<undefined>()
     const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: releaseFirst.promise }])
     const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: releaseFirst.promise }])
@@ -1779,8 +2166,8 @@ describe('continuable review regressions', () => {
     await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
     await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
     const child = ctx.agents.get(started.childId)!
     const child = ctx.agents.get(started.childId)!
 
 
-    // Cancel from the synchronous enqueue observer: the discard fires after the
-    // id is recorded but before `queuePrompt()` returns.
+    // Cancel from the synchronous enqueue observer, before `queuePrompt()`
+    // returns from Agent.followup().
     const off = child.ctx.on('agent/inbox/inserted', ({ message }) => {
     const off = child.ctx.on('agent/inbox/inserted', ({ message }) => {
       if (message.content.some(block => block.type === 'text' && block.text === 'doomed')) {
       if (message.content.some(block => block.type === 'text' && block.text === 'doomed')) {
         child.cancel({ kind: 'user' })
         child.cancel({ kind: 'user' })
@@ -1790,29 +2177,21 @@ describe('continuable review regressions', () => {
     off()
     off()
 
 
     releaseFirst.resolve(undefined)
     releaseFirst.resolve(undefined)
-    // Retaining the discarded id would pin residency at `running` forever, so
-    // reaching no-Activation without an explicit drain is the assertion.
+    // The discarded delivery leaves no phantom activity that pins residency.
     await waitNoActivation(ctx, started.childId)
     await waitNoActivation(ctx, started.childId)
     const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId)
     const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId)
     expect(hasUserText(loaded.events, 'doomed')).toBe(false)
     expect(hasUserText(loaded.events, 'doomed')).toBe(false)
   })
   })
 
 
-  it('releases older ids discarded during a later admission window', async () => {
+  it('settles after a later delivery discards older queued work', async () => {
     const releaseFirst = Promise.withResolvers<undefined>()
     const releaseFirst = Promise.withResolvers<undefined>()
     const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: releaseFirst.promise }])
     const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: releaseFirst.promise }])
     const { ctx, parent } = await setupWith(adapter)
     const { ctx, parent } = await setupWith(adapter)
     const started = await ctx.subagents.startContinuable(startSpec(parent))
     const started = await ctx.subagents.startContinuable(startSpec(parent))
     await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
     await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
     const child = ctx.agents.get(started.childId)!
     const child = ctx.agents.get(started.childId)!
-    const manager = (ctx.subagents as unknown as {
-      continuations: {
-        activations: Map<SessionId, { accepted: Set<MessageId> }>
-      }
-    }).continuations
-    const activation = manager.activations.get(started.childId)!
 
 
     await queuePrompt(ctx, parent, started.childId, message('queued'))
     await queuePrompt(ctx, parent, started.childId, message('queued'))
-    expect(activation.accepted.size).toBe(1)
     const off = child.ctx.on('agent/inbox/inserted', ({ message }) => {
     const off = child.ctx.on('agent/inbox/inserted', ({ message }) => {
       if (message.content.some(block => block.type === 'text' && block.text === 'doomed')) {
       if (message.content.some(block => block.type === 'text' && block.text === 'doomed')) {
         child.cancel({ kind: 'user' })
         child.cancel({ kind: 'user' })
@@ -1821,9 +2200,11 @@ describe('continuable review regressions', () => {
     await queuePrompt(ctx, parent, started.childId, message('doomed'))
     await queuePrompt(ctx, parent, started.childId, message('doomed'))
     off()
     off()
 
 
-    expect(activation.accepted.size).toBe(0)
     releaseFirst.resolve(undefined)
     releaseFirst.resolve(undefined)
     await waitNoActivation(ctx, started.childId)
     await waitNoActivation(ctx, started.childId)
+    const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId)
+    expect(hasUserText(loaded.events, 'queued')).toBe(false)
+    expect(hasUserText(loaded.events, 'doomed')).toBe(false)
   })
   })
 
 
   it('reports a prompt a pre-step rejection discarded as refusal', async () => {
   it('reports a prompt a pre-step rejection discarded as refusal', async () => {
@@ -2250,11 +2631,8 @@ describe('continuable settlement delivery', () => {
   it('withholds an outcome the harness could not durably release', async () => {
   it('withholds an outcome the harness could not durably release', async () => {
     const { ctx, parent } = await setup([textResponse('the answer'), textResponse('parent ack')])
     const { ctx, parent } = await setup([textResponse('the answer'), textResponse('parent ack')])
     const started = await ctx.subagents.startContinuable(startSpec(parent))
     const started = await ctx.subagents.startContinuable(startSpec(parent))
-    const manager = (ctx.subagents as unknown as {
-      continuations: { activations: Map<SessionId, { handle: { dispose(): Promise<void> } }> }
-    }).continuations
     const activation = await vi.waitFor(() => {
     const activation = await vi.waitFor(() => {
-      const live = manager.activations.get(started.childId)
+      const live = continuationActivations(ctx).get(started.childId)
       expect(live).toBeDefined()
       expect(live).toBeDefined()
       return live!
       return live!
     })
     })
@@ -2342,11 +2720,8 @@ describe('continuable settlement delivery', () => {
     const second = await ctx.subagents.startContinuable(startSpec(middle))
     const second = await ctx.subagents.startContinuable(startSpec(middle))
     await vi.waitFor(() => { expect(middle.status).toBe('idle') })
     await vi.waitFor(() => { expect(middle.status).toBe('idle') })
 
 
-    // `Agent.status` folds maintenance into `idle`, and a waking send behind it
-    // only arms a deferred wake. The first child's release moves the middle
-    // Activation's settlement watcher onto its quiescence race; the second one
-    // then arrives at exactly the point where an unaccounted delivery would be
-    // judged quiet, settled, and cancelled — clearing the inbox it sits in.
+    // `whenIdle()` follows maintenance and the deferred wake it releases, so
+    // neither settlement notice can be mistaken for completed idle work.
     const maintaining = Promise.withResolvers<undefined>()
     const maintaining = Promise.withResolvers<undefined>()
     const maintenance = middle.runMaintenance(async () => { await maintaining.promise })
     const maintenance = middle.runMaintenance(async () => { await maintaining.promise })
     releaseFirst.resolve(undefined)
     releaseFirst.resolve(undefined)
@@ -2380,13 +2755,10 @@ describe('continuable settlement delivery', () => {
     const inner = await ctx.subagents.startContinuable(startSpec(middle))
     const inner = await ctx.subagents.startContinuable(startSpec(middle))
     await vi.waitFor(() => { expect(middle.status).toBe('idle') })
     await vi.waitFor(() => { expect(middle.status).toBe('idle') })
 
 
-    const manager = (ctx.subagents as unknown as {
-      continuations: { activations: Map<SessionId, { ownedChildren: Set<SessionId> }> }
-    }).continuations
     let ownedAtDelivery: SessionId[] | undefined
     let ownedAtDelivery: SessionId[] | undefined
     ctx.on('agent/inbox/inserted', ({ agent, message }) => {
     ctx.on('agent/inbox/inserted', ({ agent, message }) => {
       if (agent !== middle || message.source.kind !== 'subagent-settled') return
       if (agent !== middle || message.source.kind !== 'subagent-settled') return
-      ownedAtDelivery = [...manager.activations.get(middle.id)!.ownedChildren]
+      ownedAtDelivery = [...continuationActivations(ctx).get(middle.id)!.ownedChildren]
     })
     })
 
 
     releaseChild.resolve(undefined)
     releaseChild.resolve(undefined)
@@ -2633,10 +3005,7 @@ describe('continuable errors', () => {
     })
     })
     // Drop the Activation without disposing the Agent, leaving the id live but
     // Drop the Activation without disposing the Agent, leaving the id live but
     // unmanaged. Materialization must not adopt it.
     // unmanaged. Materialization must not adopt it.
-    const manager = (ctx.subagents as unknown as {
-      continuations: { activations: Map<SessionId, unknown> }
-    }).continuations
-    manager.activations.delete(started.childId)
+    dropContinuationActivation(ctx, started.childId)
 
 
     await expect(queuePrompt(ctx, parent, started.childId, message('hello')))
     await expect(queuePrompt(ctx, parent, started.childId, message('hello')))
       .rejects.toThrow(SubagentError)
       .rejects.toThrow(SubagentError)
@@ -2696,10 +3065,7 @@ describe('continuable errors', () => {
     await vi.waitFor(() => { expect(ctx.agents.get(grandchild.childId)).toBeDefined() })
     await vi.waitFor(() => { expect(ctx.agents.get(grandchild.childId)).toBeDefined() })
     // Make the grandchild's own handle disposal reject: scope teardown failure
     // Make the grandchild's own handle disposal reject: scope teardown failure
     // propagates, unlike a contained `agent/disposed` listener throw.
     // propagates, unlike a contained `agent/disposed` listener throw.
-    const manager = (ctx.subagents as unknown as {
-      continuations: { activations: Map<SessionId, { handle: { dispose: () => Promise<void> } }> }
-    }).continuations
-    const branch = manager.activations.get(grandchild.childId)!
+    const branch = continuationActivations(ctx).get(grandchild.childId)!
     const realDispose = branch.handle.dispose.bind(branch.handle)
     const realDispose = branch.handle.dispose.bind(branch.handle)
     branch.handle.dispose = async () => {
     branch.handle.dispose = async () => {
       await realDispose()
       await realDispose()
@@ -2730,11 +3096,8 @@ describe('continuable errors', () => {
     })
     })
     // The would-be parent's disposal is already open at the entry hold, so the
     // The would-be parent's disposal is already open at the entry hold, so the
     // establishment rejects before any grandchild resource exists.
     // establishment rejects before any grandchild resource exists.
-    const manager = (ctx.subagents as unknown as {
-      continuations: { activations: Map<SessionId, { disposal: Promise<void> | undefined }> }
-    }).continuations
     const before = new Set(ctx.agents.list().map(agent => agent.id))
     const before = new Set(ctx.agents.list().map(agent => agent.id))
-    manager.activations.get(outer.childId)!.disposal = Promise.resolve()
+    void continuationActivations(ctx).get(outer.childId)!.inbox.close(() => Promise.resolve())
 
 
     await expect(ctx.subagents.startContinuable(startSpec(child)))
     await expect(ctx.subagents.startContinuable(startSpec(child)))
       .rejects.toMatchObject({ code: 'ACTIVATION_CLOSING' })
       .rejects.toMatchObject({ code: 'ACTIVATION_CLOSING' })
@@ -2757,13 +3120,8 @@ describe('continuable errors', () => {
       expect(found).toBeDefined()
       expect(found).toBeDefined()
       return found!
       return found!
     })
     })
-    const manager = (ctx.subagents as unknown as {
-      continuations: {
-        activations: Map<SessionId, { disposal: Promise<void> | undefined }>
-        ownerCtx: Context
-      }
-    }).continuations
-    const ownerAgents = manager.ownerCtx.agents
+    const activations = continuationActivations(ctx)
+    const ownerAgents = activations.ownerCtx.agents
     const before = new Set(ctx.agents.list().map(agent => agent.id))
     const before = new Set(ctx.agents.list().map(agent => agent.id))
     // Open the would-be parent's disposal only once the grandchild's Agent is
     // Open the would-be parent's disposal only once the grandchild's Agent is
     // being created: the entry hold has already passed, so the post-transfer
     // being created: the entry hold has already passed, so the post-transfer
@@ -2771,7 +3129,7 @@ describe('continuable errors', () => {
     // Activation and no live Agent left behind.
     // Activation and no live Agent left behind.
     const originalCreate = ownerAgents.create.bind(ownerAgents)
     const originalCreate = ownerAgents.create.bind(ownerAgents)
     const createSpy = vi.spyOn(ownerAgents, 'create').mockImplementation((options) => {
     const createSpy = vi.spyOn(ownerAgents, 'create').mockImplementation((options) => {
-      manager.activations.get(outer.childId)!.disposal = Promise.resolve()
+      void activations.get(outer.childId)!.inbox.close(() => Promise.resolve())
       createSpy.mockRestore()
       createSpy.mockRestore()
       return originalCreate(options)
       return originalCreate(options)
     })
     })
@@ -2901,7 +3259,7 @@ describe('continuable errors', () => {
 })
 })
 
 
 describe('SubagentRuntime.interrupt', () => {
 describe('SubagentRuntime.interrupt', () => {
-  it('aborts the current turn durably, parks accepted follow-ups, and resumes them only on a waking send', async () => {
+  it('aborts the current turn durably, parks accepted follow-ups, and settles after direct Agent followup', async () => {
     const releaseFirst = Promise.withResolvers<undefined>()
     const releaseFirst = Promise.withResolvers<undefined>()
     const adapter = new GatedAdapter([
     const adapter = new GatedAdapter([
       { chunks: textResponse('first'), gate: releaseFirst.promise },
       { chunks: textResponse('first'), gate: releaseFirst.promise },
@@ -2924,6 +3282,7 @@ describe('SubagentRuntime.interrupt', () => {
     // Cancellation is cooperative: the held model call observes it on release.
     // Cancellation is cooperative: the held model call observes it on release.
     releaseFirst.resolve(undefined)
     releaseFirst.resolve(undefined)
     await child.whenIdle()
     await child.whenIdle()
+    await passSettlementCheck(ctx, started.childId)
     // Parked, not resumed: no second model request follows the abort, the
     // Parked, not resumed: no second model request follows the abort, the
     // accepted follow-ups stay pending, and the same Activation stays resident.
     // accepted follow-ups stay pending, and the same Activation stays resident.
     expect(adapter.requests).toHaveLength(1)
     expect(adapter.requests).toHaveLength(1)
@@ -2931,9 +3290,9 @@ describe('SubagentRuntime.interrupt', () => {
     expect(child.status).toBe('idle')
     expect(child.status).toBe('idle')
     expect(ctx.agents.get(started.childId)).toBe(child)
     expect(ctx.agents.get(started.childId)).toBe(child)
 
 
-    // Only an explicit waking send restores the driver; the parked items then
-    // run before it in the existing FIFO order.
-    await queuePrompt(ctx, parent, started.childId, message('waking D'))
+    // A host can wake a resident child through Agent directly; the parked items
+    // still run before the new message in the existing FIFO order.
+    child.followup(createUserMessage({ content: message('waking D'), source: { kind: 'user' } }))
     await waitNoActivation(ctx, started.childId)
     await waitNoActivation(ctx, started.childId)
     const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId)
     const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId)
     expect(userTexts(loaded.events)).toEqual(['child task', 'parked B', 'parked C', 'waking D'])
     expect(userTexts(loaded.events)).toEqual(['child task', 'parked B', 'parked C', 'waking D'])

+ 20 - 1
packages/subagent/subagent/tests/control.spec.ts

@@ -43,12 +43,13 @@ function childRow(id: SessionId, activity: 'running' | 'inactive'): SubagentList
   return { kind: 'child', id, mode: 'continuable', label: 'worker', activity, hasChildren: false }
   return { kind: 'child', id, mode: 'continuable', label: 'worker', activity, hasChildren: false }
 }
 }
 
 
-function promptRequest(clientTimeZone?: string) {
+function promptRequest(clientTimeZone?: string, delivery: 'queue' | 'steer' = 'queue') {
   return {
   return {
     requestId: REQUEST_ID,
     requestId: REQUEST_ID,
     parentSessionId: PARENT,
     parentSessionId: PARENT,
     childSessionId: CHILD,
     childSessionId: CHILD,
     mode: 'continuable' as const,
     mode: 'continuable' as const,
+    delivery,
     content: [{ type: 'text' as const, text: 'continue' }],
     content: [{ type: 'text' as const, text: 'continue' }],
     ...clientTimeZone === undefined ? {} : { clientTimeZone },
     ...clientTimeZone === undefined ? {} : { clientTimeZone },
   }
   }
@@ -165,6 +166,15 @@ describe('subagent prompt Remote', () => {
     expect(delivery).not.toHaveBeenCalled()
     expect(delivery).not.toHaveBeenCalled()
   })
   })
 
 
+  it('rejects an unknown delivery before admission', async () => {
+    const { subagents } = await bench({ [PARENT]: { status: 'idle' } })
+    const delivery = promptDelivery(subagents)
+
+    await expect(subagents.prompt({ ...promptRequest(), delivery: 'later' as 'queue' }, signal))
+      .rejects.toMatchObject({ code: 'gateway/bad-request' })
+    expect(delivery).not.toHaveBeenCalled()
+  })
+
   it('admits ordered image parts into durable references before delivery', async () => {
   it('admits ordered image parts into durable references before delivery', async () => {
     const { ctx, subagents } = await bench({ [PARENT]: { status: 'idle' } })
     const { ctx, subagents } = await bench({ [PARENT]: { status: 'idle' } })
     const saveImages = vi.fn(async (inputs: readonly { mediaType: string }[]) =>
     const saveImages = vi.fn(async (inputs: readonly { mediaType: string }[]) =>
@@ -258,6 +268,15 @@ describe('subagent prompt Remote', () => {
     )
     )
   })
   })
 
 
+  it('passes steer delivery through the same admission operation', async () => {
+    const { subagents } = await bench({ [PARENT]: { status: 'running' } })
+    const delivery = promptDelivery(subagents).mockResolvedValue('m-steer' as MessageId)
+
+    await expect(subagents.prompt(promptRequest(undefined, 'steer'), signal))
+      .resolves.toEqual({ messageId: 'm-steer' })
+    expect(delivery.mock.calls[0]?.[5]).toBe('steer')
+  })
+
   it('omits the zone from the durable source when the browser reported none', async () => {
   it('omits the zone from the durable source when the browser reported none', async () => {
     const { subagents } = await bench({ [PARENT]: { status: 'idle' } })
     const { subagents } = await bench({ [PARENT]: { status: 'idle' } })
     const delivery = promptDelivery(subagents).mockResolvedValue('m-2' as MessageId)
     const delivery = promptDelivery(subagents).mockResolvedValue('m-2' as MessageId)

+ 5 - 2
packages/subagent/subagent/tests/list-children.spec.ts

@@ -35,9 +35,11 @@ import { seedStoredSession } from './persistence-helpers.ts'
 type Script = ConstructorParameters<typeof MockAdapter>[0]
 type Script = ConstructorParameters<typeof MockAdapter>[0]
 
 
 const roots: string[] = []
 const roots: string[] = []
+const persistenceDisposers: Array<() => Promise<void>> = []
 const projCacheRoots: string[] = []
 const projCacheRoots: string[] = []
 
 
-afterEach(() => {
+afterEach(async () => {
+  await Promise.all(persistenceDisposers.splice(0).map(dispose => dispose()))
   for (const root of projCacheRoots.splice(0)) rmSync(root, { recursive: true, force: true })
   for (const root of projCacheRoots.splice(0)) rmSync(root, { recursive: true, force: true })
   for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
   for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
 })
 })
@@ -51,7 +53,8 @@ async function setup(
   await mountAgentLoopTestDependencies(ctx)
   await mountAgentLoopTestDependencies(ctx)
   const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-list-'))
   const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-list-'))
   roots.push(root)
   roots.push(root)
-  await ctx.plugin(JsonlSessionPersistence, { root })
+  const persistence = await ctx.plugin(JsonlSessionPersistence, { root })
+  persistenceDisposers.push(() => persistence.dispose())
   await ctx.plugin(AgentLoop, { agents: [] })
   await ctx.plugin(AgentLoop, { agents: [] })
   if (options.sessionProjections !== false) await ctx.plugin(SessionProjectionRegistry)
   if (options.sessionProjections !== false) await ctx.plugin(SessionProjectionRegistry)
   if (options.projectionCache === true) {
   if (options.projectionCache === true) {

+ 25 - 0
packages/test-support/session-snapshot/tests/fixtures/subagent-durability-failure.ts

@@ -1,5 +1,6 @@
 import type { Context } from '@deepseek-ai/cordis'
 import type { Context } from '@deepseek-ai/cordis'
 import { SessionId } from '@deepseek-ai/dsh-session'
 import { SessionId } from '@deepseek-ai/dsh-session'
+import type { SubagentPromptRequestId } from '@deepseek-ai/dsh-subagent'
 
 
 export const name = 'subagent-durability-failure'
 export const name = 'subagent-durability-failure'
 export const inject = ['agents', 'sessionPersistence', 'subagents']
 export const inject = ['agents', 'sessionPersistence', 'subagents']
@@ -12,6 +13,9 @@ export const inject = ['agents', 'sessionPersistence', 'subagents']
  *
  *
  *  - `PLACEHOLDER_CHILD_ID` in a scripted `send_message` is remapped to the real
  *  - `PLACEHOLDER_CHILD_ID` in a scripted `send_message` is remapped to the real
  *    child so both follow-ups queue onto the same live inbox in FIFO order.
  *    child so both follow-ups queue onto the same live inbox in FIFO order.
+ *  - Under `DSH_SNAPSHOT_HUMAN_STEER`, a browser-authored prompt steers the
+ *    continuable child before its first step, recording the shared next-step
+ *    inbox path without adding a model tool.
  *  - The unknown-id `send_message` (`UNKNOWN_CHILD_ID`) resolves through a
  *  - The unknown-id `send_message` (`UNKNOWN_CHILD_ID`) resolves through a
  *    persistence stat fenced behind both accepted follow-ups, so the transcript
  *    persistence stat fenced behind both accepted follow-ups, so the transcript
  *    records the same order on every runner.
  *    records the same order on every runner.
@@ -34,6 +38,7 @@ export function apply(ctx: Context): void {
   const parentTurnClosed = Promise.withResolvers<undefined>()
   const parentTurnClosed = Promise.withResolvers<undefined>()
   let parentClosed = false
   let parentClosed = false
   const publishedFailure = process.env.DSH_SUBAGENT_PUBLISHED_FAILURE === '1'
   const publishedFailure = process.env.DSH_SUBAGENT_PUBLISHED_FAILURE === '1'
+  const humanSteer = process.env.DSH_SNAPSHOT_HUMAN_STEER === '1'
   const persistence = ctx.sessionPersistence
   const persistence = ctx.sessionPersistence
   const stat = persistence.stat.bind(persistence)
   const stat = persistence.stat.bind(persistence)
   const agents = ctx.agents
   const agents = ctx.agents
@@ -87,6 +92,14 @@ export function apply(ctx: Context): void {
   let realChildId: string | undefined
   let realChildId: string | undefined
   const subagents = ctx.subagents as unknown as {
   const subagents = ctx.subagents as unknown as {
     sendMessage: (authority: unknown, childId: SessionId, content: unknown, options: unknown) => Promise<unknown>
     sendMessage: (authority: unknown, childId: SessionId, content: unknown, options: unknown) => Promise<unknown>
+    prompt: (request: {
+      requestId: SubagentPromptRequestId
+      parentSessionId: SessionId
+      childSessionId: SessionId
+      mode: 'continuable'
+      delivery: 'steer'
+      content: readonly [{ readonly type: 'text'; readonly text: string }]
+    }, signal: AbortSignal) => Promise<unknown>
   }
   }
   const deliver = subagents.sendMessage.bind(subagents)
   const deliver = subagents.sendMessage.bind(subagents)
   subagents.sendMessage = (authority, childId, content, options) => {
   subagents.sendMessage = (authority, childId, content, options) => {
@@ -106,9 +119,21 @@ export function apply(ctx: Context): void {
     accepted += 1
     accepted += 1
     if (accepted >= 3) followupsAccepted.resolve(undefined)
     if (accepted >= 3) followupsAccepted.resolve(undefined)
   })
   })
+  let steering = false
   ctx.on('agent/pre-step', async ({ agent }, next) => {
   ctx.on('agent/pre-step', async ({ agent }, next) => {
     if (agent.session.header.parentSession === undefined) return next()
     if (agent.session.header.parentSession === undefined) return next()
     await followupsAccepted.promise
     await followupsAccepted.promise
+    if (humanSteer && !steering) {
+      steering = true
+      await subagents.prompt({
+        requestId: 'snapshot-human-steer' as SubagentPromptRequestId,
+        parentSessionId: agent.session.header.parentSession,
+        childSessionId: SessionId(agent.session.header.id),
+        mode: 'continuable',
+        delivery: 'steer',
+        content: [{ type: 'text', text: 'Human priority: keep the requested exact reply.' }],
+      }, new AbortController().signal)
+    }
     // The published-failure variant's child never reaches a step (its follow-up
     // The published-failure variant's child never reaches a step (its follow-up
     // throws), and its parent turn awaits that child, so only the continuable
     // throws), and its parent turn awaits that child, so only the continuable
     // scenario takes the settlement fence.
     // scenario takes the settlement fence.

+ 3 - 0
pnpm-lock.yaml

@@ -946,6 +946,9 @@ importers:
       '@deepseek-ai/dsh-util-time':
       '@deepseek-ai/dsh-util-time':
         specifier: workspace:^
         specifier: workspace:^
         version: link:../../util/time
         version: link:../../util/time
+      '@deepseek-ai/dsh-util-values':
+        specifier: workspace:^
+        version: link:../../util/values
       '@deepseek-ai/dsh-util-workspace-path':
       '@deepseek-ai/dsh-util-workspace-path':
         specifier: workspace:^
         specifier: workspace:^
         version: link:../../util/workspace-path
         version: link:../../util/workspace-path

+ 5 - 5
scripts/type-equiv.manifest.json

@@ -1349,22 +1349,22 @@
     {
     {
       "doc": "docs/subsystems/subagent.md",
       "doc": "docs/subsystems/subagent.md",
       "symbol": "AgentMessageSource",
       "symbol": "AgentMessageSource",
-      "source": "packages/subagent/subagent/src/continuation.ts"
+      "source": "packages/subagent/subagent/src/continuation-messages.ts"
     },
     },
     {
     {
       "doc": "docs/subsystems/subagent.md",
       "doc": "docs/subsystems/subagent.md",
       "symbol": "SubagentSettledMessageSource",
       "symbol": "SubagentSettledMessageSource",
-      "source": "packages/subagent/subagent/src/continuation.ts"
+      "source": "packages/subagent/subagent/src/continuation-messages.ts"
     },
     },
     {
     {
       "doc": "docs/subsystems/subagent.md",
       "doc": "docs/subsystems/subagent.md",
       "symbol": "SubagentSendMessageOptions",
       "symbol": "SubagentSendMessageOptions",
-      "source": "packages/subagent/subagent/src/continuation.ts"
+      "source": "packages/subagent/subagent/src/types.ts"
     },
     },
     {
     {
       "doc": "docs/subsystems/subagent.md",
       "doc": "docs/subsystems/subagent.md",
       "symbol": "SubagentInterruptAuthority",
       "symbol": "SubagentInterruptAuthority",
-      "source": "packages/subagent/subagent/src/continuation.ts"
+      "source": "packages/subagent/subagent/src/types.ts"
     },
     },
     {
     {
       "doc": "docs/subsystems/subagent.md",
       "doc": "docs/subsystems/subagent.md",
@@ -1374,7 +1374,7 @@
     {
     {
       "doc": "docs/subsystems/subagent.md",
       "doc": "docs/subsystems/subagent.md",
       "symbol": "ContinuableStart",
       "symbol": "ContinuableStart",
-      "source": "packages/subagent/subagent/src/continuation.ts"
+      "source": "packages/subagent/subagent/src/types.ts"
     },
     },
     {
     {
       "doc": "docs/subsystems/subagent.md",
       "doc": "docs/subsystems/subagent.md",

+ 3 - 0
snapshots/sdk/sdk.snapshot.ts

@@ -127,6 +127,9 @@ const SDK_ASSERTIONS: Readonly<Record<string, SdkAssertions>> = {
     expectedFinalResponse: 'CODE_ONE+CODE_TWO',
     expectedFinalResponse: 'CODE_ONE+CODE_TWO',
     expectedTools: { run_code: ['code', 'description'] },
     expectedTools: { run_code: ['code', 'description'] },
   },
   },
+  'subagent-continuable': {
+    environment: { DSH_SNAPSHOT_HUMAN_STEER: '1' },
+  },
   'subagent-dsh-sdk-diagnostic': {
   'subagent-dsh-sdk-diagnostic': {
     environment: { DSH_TEST_CHILD_PATCH: dshSdkDiagnosticChildPatch },
     environment: { DSH_TEST_CHILD_PATCH: dshSdkDiagnosticChildPatch },
   },
   },

+ 7 - 5
snapshots/sdk/subagent-continuable/session.1.v2.jsonl

@@ -8,18 +8,20 @@
 {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
 {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
 {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Agent {{session:1}} sent a message: "},{"type":"text","text":"Now reply with exactly SECOND_OK."}],"source":{"kind":"agent-message","form":"relay","senderSessionId":"{{session:1}}"},"role":"user","id":"{{message:15}}"}]}}
 {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Agent {{session:1}} sent a message: "},{"type":"text","text":"Now reply with exactly SECOND_OK."}],"source":{"kind":"agent-message","form":"relay","senderSessionId":"{{session:1}}"},"role":"user","id":"{{message:15}}"}]}}
 {"type":"agent/inbox/spliced","data":{"target":"next-step","start":1,"inserted":[{"content":[{"type":"text","text":"Agent {{session:1}} sent a message: "},{"type":"text","text":"Now reply with exactly THIRD_OK."}],"source":{"kind":"agent-message","form":"relay","senderSessionId":"{{session:1}}"},"role":"user","id":"{{message:16}}"}]}}
 {"type":"agent/inbox/spliced","data":{"target":"next-step","start":1,"inserted":[{"content":[{"type":"text","text":"Agent {{session:1}} sent a message: "},{"type":"text","text":"Now reply with exactly THIRD_OK."}],"source":{"kind":"agent-message","form":"relay","senderSessionId":"{{session:1}}"},"role":"user","id":"{{message:16}}"}]}}
+{"type":"agent/inbox/spliced","data":{"target":"next-step","start":2,"inserted":[{"content":[{"type":"text","text":"Human priority: keep the requested exact reply."}],"source":{"kind":"user","rpcId":"{{rpc:1}}"},"role":"user","id":"{{message:17}}"}]}}
 {"type":"step/start","data":{"turn":1,"step":1}}
 {"type":"step/start","data":{"turn":1,"step":1}}
 {"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."},{"type":"text","text":"Your parent agent id is \"{{session:1}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"{{session:1}}\", message: \"<self-contained result>\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn."}],"source":{"kind":"user"},"role":"user","id":"{{message:14}}"},"surfaceOp":"append"}
 {"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."},{"type":"text","text":"Your parent agent id is \"{{session:1}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"{{session:1}}\", message: \"<self-contained result>\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn."}],"source":{"kind":"user"},"role":"user","id":"{{message:14}}"},"surfaceOp":"append"}
-{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{message:17}}"},"surfaceOp":"append"}
-{"type":"session/title","data":{"title":"Reply with exactly the word","messageSeqs":[10],"source":{"kind":"fallback"}}}
+{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{message:18}}"},"surfaceOp":"append"}
+{"type":"session/title","data":{"title":"Reply with exactly the word","messageSeqs":[11],"source":{"kind":"fallback"}}}
 {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
 {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
 {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
 {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
-{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:18}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1788269696690,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1788269696690,"index":0,"dt":[],"texts":["CHILD_OK"]},{"type":"chunk","time":1788269696691,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_OK"}}},{"type":"chunk","time":1788269696691,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1788269696691,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
+{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:19}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1788505934036,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1788505934036,"index":0,"dt":[],"texts":["CHILD_OK"]},{"type":"chunk","time":1788505934036,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_OK"}}},{"type":"chunk","time":1788505934036,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1788505934036,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":1,"step":1}}
 {"type":"step/end","data":{"turn":1,"step":1}}
-{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":2,"inserted":[]}}
+{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":3,"inserted":[]}}
 {"type":"step/start","data":{"turn":1,"step":2}}
 {"type":"step/start","data":{"turn":1,"step":2}}
 {"type":"user/message","data":{"content":[{"type":"text","text":"Agent {{session:1}} sent a message: "},{"type":"text","text":"Now reply with exactly SECOND_OK."}],"source":{"kind":"agent-message","form":"relay","senderSessionId":"{{session:1}}"},"role":"user","id":"{{message:15}}"},"surfaceOp":"append"}
 {"type":"user/message","data":{"content":[{"type":"text","text":"Agent {{session:1}} sent a message: "},{"type":"text","text":"Now reply with exactly SECOND_OK."}],"source":{"kind":"agent-message","form":"relay","senderSessionId":"{{session:1}}"},"role":"user","id":"{{message:15}}"},"surfaceOp":"append"}
 {"type":"user/message","data":{"content":[{"type":"text","text":"Agent {{session:1}} sent a message: "},{"type":"text","text":"Now reply with exactly THIRD_OK."}],"source":{"kind":"agent-message","form":"relay","senderSessionId":"{{session:1}}"},"role":"user","id":"{{message:16}}"},"surfaceOp":"append"}
 {"type":"user/message","data":{"content":[{"type":"text","text":"Agent {{session:1}} sent a message: "},{"type":"text","text":"Now reply with exactly THIRD_OK."}],"source":{"kind":"agent-message","form":"relay","senderSessionId":"{{session:1}}"},"role":"user","id":"{{message:16}}"},"surfaceOp":"append"}
-{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"SECOND_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:19}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1788269696707,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1788269696707,"index":0,"dt":[],"texts":["SECOND_OK"]},{"type":"chunk","time":1788269696707,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SECOND_OK"}}},{"type":"chunk","time":1788269696707,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1788269696707,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
+{"type":"user/message","data":{"content":[{"type":"text","text":"Human priority: keep the requested exact reply."}],"source":{"kind":"user","rpcId":"{{rpc:1}}"},"role":"user","id":"{{message:17}}"},"surfaceOp":"append"}
+{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"SECOND_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:20}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1788505934049,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1788505934049,"index":0,"dt":[],"texts":["SECOND_OK"]},{"type":"chunk","time":1788505934049,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SECOND_OK"}}},{"type":"chunk","time":1788505934049,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1788505934049,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":1,"step":2}}
 {"type":"step/end","data":{"turn":1,"step":2}}
 {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
 {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}

+ 9 - 0
snapshots/web/subagent-interrupt/offline-composer.expected.md

@@ -24,6 +24,15 @@
   - text: Context injection @deepseek-ai/dsh-system-prompt
   - text: Context injection @deepseek-ai/dsh-system-prompt
 - paragraph: partial
 - paragraph: partial
 - status: Deep diving...
 - status: Deep diving...
+- list:
+  - listitem:
+    - text: Keep working until I stop you again.
+    - button "Edit queued message":
+      - img
+    - button "Remove queued message":
+      - img
+    - button "Steer queued message":
+      - img
 - textbox "Parent session offline; sending is unavailable but you can still stop the run" [disabled]
 - textbox "Parent session offline; sending is unavailable but you can still stop the run" [disabled]
 - button "Commands" [disabled]:
 - button "Commands" [disabled]:
   - img
   - img

部分文件因为文件数量过多而无法显示