Просмотр исходного кода

fix(web): restore pending Inbox after restart

_Kerman 3 недель назад
Родитель
Сommit
8c8c5b2680
89 измененных файлов с 522 добавлено и 978 удалено
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml
  2. 1 1
      .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md
  3. 1 1
      .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml
  5. 1 1
      .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md
  6. 1 1
      .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md
  7. 2 2
      .agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.i18n.yaml
  8. 2 2
      .agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md
  9. 2 2
      .agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md
  10. 2 2
      .agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml
  11. 5 5
      .agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md
  12. 5 5
      .agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md
  13. 2 2
      .agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.i18n.yaml
  14. 3 3
      .agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.md
  15. 3 3
      .agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.zh.md
  16. 2 2
      .agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.i18n.yaml
  17. 28 8
      .agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md
  18. 28 8
      .agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.zh.md
  19. 2 2
      .agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml
  20. 11 15
      .agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md
  21. 11 15
      .agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md
  22. 2 2
      .agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.i18n.yaml
  23. 2 2
      .agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md
  24. 2 2
      .agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md
  25. 2 2
      .agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml
  26. 1 1
      .agents/notes/implemented/feature/2026-08-08-web-background-job-display.md
  27. 1 1
      .agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md
  28. 2 2
      .agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml
  29. 1 1
      .agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md
  30. 1 1
      .agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md
  31. 2 2
      .agents/notes/proposed/feature/2026-08-04-task-surface.i18n.yaml
  32. 2 14
      .agents/notes/proposed/feature/2026-08-04-task-surface.md
  33. 2 14
      .agents/notes/proposed/feature/2026-08-04-task-surface.zh.md
  34. 21 1
      apps/web/tests/cold-blank-session.e2e.ts
  35. 9 0
      apps/web/tests/expected/cold-blank-session/queue.expected.md
  36. 4 1
      apps/web/tests/expected/cold-blank-session/sidebar.expected.md
  37. 36 1
      apps/web/tests/scaffold.ts
  38. 3 4
      apps/web/tests/steering.e2e.ts
  39. 2 2
      docs/event-producer-consumer.i18n.yaml
  40. 5 5
      docs/event-producer-consumer.md
  41. 5 5
      docs/event-producer-consumer.zh.md
  42. 2 2
      packages/api/session-controller/README.i18n.yaml
  43. 1 1
      packages/api/session-controller/README.md
  44. 1 1
      packages/api/session-controller/README.zh.md
  45. 0 13
      packages/api/session-controller/src/client/contract/snapshot.ts
  46. 0 1
      packages/api/session-controller/src/client/index.ts
  47. 3 25
      packages/api/session-controller/src/client/sessions/manager.ts
  48. 0 67
      packages/api/session-controller/src/client/sessions/queue-mirror.ts
  49. 2 27
      packages/api/session-controller/src/client/sessions/session.ts
  50. 2 36
      packages/api/session-controller/src/control.ts
  51. 0 13
      packages/api/session-controller/src/types.ts
  52. 1 1
      packages/api/session-controller/tests/client-apply.client.spec.ts
  53. 0 168
      packages/api/session-controller/tests/control-queue.host.spec.ts
  54. 0 1
      packages/api/session-controller/tests/fake-api.client.ts
  55. 3 3
      packages/api/session-controller/tests/manager.client.spec.ts
  56. 1 1
      packages/api/session-controller/tests/projection-store.client.spec.ts
  57. 66 253
      packages/api/session-controller/tests/queue-store.client.spec.ts
  58. 1 1
      packages/api/session-controller/tests/sessions-service.client.spec.ts
  59. 3 3
      packages/api/session-controller/tests/transport.client.spec.ts
  60. 0 4
      packages/client/connection/src/client/fixture.ts
  61. 0 1
      packages/client/connection/tests/fixture.client.spec.ts
  62. 5 3
      packages/client/ui-chat/src/client/chat/ChatView.tsx
  63. 34 39
      packages/client/ui-chat/tests/chat-view.client.spec.tsx
  64. 0 1
      packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx
  65. 2 2
      packages/client/ui-conversation/README.i18n.yaml
  66. 1 1
      packages/client/ui-conversation/README.md
  67. 1 1
      packages/client/ui-conversation/README.zh.md
  68. 2 0
      packages/client/ui-conversation/package.json
  69. 3 6
      packages/client/ui-conversation/src/client/contract/input.ts
  70. 0 11
      packages/client/ui-conversation/src/client/contract/queue.ts
  71. 7 6
      packages/client/ui-conversation/src/client/input/facade.ts
  72. 4 3
      packages/client/ui-conversation/src/client/input/hub.ts
  73. 0 25
      packages/client/ui-conversation/src/client/input/queue-store.ts
  74. 41 22
      packages/client/ui-conversation/src/client/queue/QueueDock.tsx
  75. 4 3
      packages/client/ui-conversation/src/client/service.ts
  76. 1 1
      packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
  77. 0 1
      packages/client/ui-conversation/tests/conversation-registry.client.spec.ts
  78. 13 23
      packages/client/ui-conversation/tests/input-bar.client.spec.tsx
  79. 53 30
      packages/client/ui-conversation/tests/queue-dock.client.spec.tsx
  80. 19 19
      packages/client/ui-conversation/tests/service-orchestration.client.spec.ts
  81. 0 1
      packages/client/ui-session/tests/ui-session.client.spec.ts
  82. 0 1
      packages/client/ui-trajectory/tests/views.client.spec.tsx
  83. 0 1
      packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx
  84. 0 1
      packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx
  85. 5 1
      packages/extensions/cordis-client-runner/src/client/api-catalog.ts
  86. 6 6
      packages/extensions/tool-cordis/src/api-catalog.ts
  87. 0 1
      packages/test-support/client-runtime/src/fixtures.ts
  88. 11 0
      packages/test-support/client-runtime/src/sessions.ts
  89. 3 0
      pnpm-lock.yaml

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml

@@ -2,5 +2,5 @@
 # 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/architecture/2026-07-19-gui-web-client-architecture.md
-2026-07-19-gui-web-client-architecture.md: 4448fd5c6871d67b30b71cfe4377682639704235
-2026-07-19-gui-web-client-architecture.zh.md: e8a8121a1a495db7f5392e288f3bcada92c70495
+2026-07-19-gui-web-client-architecture.md: f55723899fdec8b8d6624ae41b7eb58e55db21ca
+2026-07-19-gui-web-client-architecture.zh.md: bda5322625bf5bfc85c31b82c688ec7b459a2226

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md

@@ -69,7 +69,7 @@ Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES──
 ```
 
 - **Session** (session.ts): lazily built, resident — once created it keeps eating frames in the background, so switching away and back renders instantly. Operations: `prompt`/`cancel` (RPC passthrough; failures land in the snapshot's `promptError`), `open` (pull the tail history page, idempotent), `loadOlder` (upward paging, reentry-guarded), `resync` (reconnect = clear the window and rerun open). Subscription: `subscribe`/`getSnapshot` (always the cached reference) — `implements ObservableSnapshot<ConversationSnapshot>`, with `useSelector = bindSnapshotSelector(this)` attached at construction, so a Session is directly a uSES source. Frame dispatch is one switch: `session/event` frames dedup by seq (the only dedup key), buffer while open is in flight, otherwise append + incremental projection; open/stitch merges the live buffer by seq and backfills once if `subscribed.lastSeq` outruns the window tail.
-- **ConversationSnapshot** (conversation.ts): the top-level immutable snapshot contract. `chat` contains structural `order`, an identity-stable keyed Node reader, Turn/Step indexes, and the timeline; `nodes`, `partial`, `runningCalls`, `turnTimings`, and `turnEnds` are the compatibility slice for unmigrated Trajectory consumers. Pending interactions, queue, running, removal, open state, paging, and prompt errors remain Session facts. **Reference discipline** (the premise of memo and uSES): unchanged substructures and Node values keep their references; one business update replaces only the corresponding key's value unless its order or Location changes. React still subscribes to the Session as the sole observable source, while the framework-provided `useSession(selector)` isolates Node and Location aggregate updates.
+- **ConversationSnapshot** (conversation.ts): the top-level immutable snapshot contract. `chat` contains structural `order`, an identity-stable keyed Node reader, Turn/Step indexes, and the timeline; `nodes`, `partial`, `runningCalls`, `turnTimings`, and `turnEnds` are the compatibility slice for unmigrated Trajectory consumers. Pending interactions, running, removal, open state, paging, and prompt errors remain Session facts; pending Inbox values live in the generic Session projection store. **Reference discipline** (the premise of memo and uSES): unchanged substructures and Node values keep their references; one business update replaces only the corresponding key's value unless its order or Location changes. React reads Session lifecycle through `useSession(selector)` and domain projections through `useProjection(key, selector)`, so each hook isolates unrelated updates.
 - **SessionManager** (manager.ts): instance cluster + frame entry + the session list. sessionId-bearing frames go only to existing instances (a mux broadcast must not instantiate every session); approval/question `requested` frames are the exception — they never land in history, so they buffer in `pendingBuffers` and replay on instantiation.
 - **Notifier** (notifier.ts): two channels chosen by change source. `markDirty()` (default; frame-driven changes always) batches per microtask — N changes, one notification, one re-render; the flush rebuilds the snapshot cache before notifying. `notifyNow()` (only direct echoes of user gestures) rebuilds and notifies in the same tick — controlled inputs roll the DOM back and jump the caret if their echo defers to a microtask. Frame-driven code using notifyNow collapses batching back to per-frame renders; banned.
 - **ConversationNodeAssembler** (`runtime/src/client/conversation/`): the Session-owned incremental engine runs independently registered Definitions over raw events. `match(event)` selects `(kind, id)` without Context scans; start/update build Definition state; engine-computed Locations carry Turn/Step closure; backward Context reads record dependencies repaired by later prepends; `buildViewNode(target)` materializes only dirty Contexts. The Chat builder preserves structural order and per-key value identity, `useSession` selectors isolate consumption, and Assistant token publication coalesces to one animation frame. The [Conversation Node decision](2026-08-09-client-conversation-node-assembly.md) owns assembly, while [Tool presentation ownership](2026-08-08-client-tool-presentation-ownership.md) owns recursive Tool rendering.

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md

@@ -69,7 +69,7 @@ Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES──
 ```
 
 - **Session**(session.ts):懒建、常驻——建成后在后台持续吃帧,切走切回秒显。操作面:`prompt`/`cancel`(RPC 透传;失败落进快照的 `promptError`)、`open`(拉尾页 history,幂等)、`loadOlder`(向上翻页,防重入)、`resync`(重连 = 清窗口重跑 open)。订阅面:`subscribe`/`getSnapshot`(恒返缓存引用)——`implements ObservableSnapshot<ConversationSnapshot>`,构造时挂 `useSelector = bindSnapshotSelector(this)`,Session 本身就是 uSES 源。帧分发是一个 switch:`session/event` 帧按 seq 去重(唯一去重键),open 在途时缓冲,否则追加 + 增量投影;open/缝合按 seq 合并 live 缓冲并去重,`subscribed.lastSeq` 超出窗口尾则回补一次。
-- **ConversationSnapshot**(conversation.ts):顶层不可变快照约定。`chat` 包含结构化 `order`、identity 稳定的 keyed Node reader、Turn/Step index 和 timeline;`nodes`、`partial`、`runningCalls`、`turnTimings`、`turnEnds` 是未迁移 Trajectory 消费方使用的兼容 slice。pending interaction、queue、running、removed、open state、paging 和 prompt error 仍是 Session 信息。**引用纪律**(memo 与 uSES 的前提):未变化的子结构和 Node value 保持引用;单个业务更新只替换对应 key 的 value,除非它的顺序或 Location 发生变化。React 仍只订阅 Session 这一处 observable source,并由框架提供的 `useSession(selector)` 隔离 Node 与 Location 聚合更新。
+- **ConversationSnapshot**(conversation.ts):顶层不可变快照约定。`chat` 包含结构化 `order`、identity 稳定的 keyed Node reader、Turn/Step index 和 timeline;`nodes`、`partial`、`runningCalls`、`turnTimings`、`turnEnds` 是未迁移 Trajectory 消费方使用的兼容 slice。pending interaction、running、removed、open state、paging 和 prompt error 仍是 Session 信息;待处理 Inbox 值则位于通用 Session projection store。**引用纪律**(memo 与 uSES 的前提):未变化的子结构和 Node value 保持引用;单个业务更新只替换对应 key 的 value,除非它的顺序或 Location 发生变化。React 通过 `useSession(selector)` 读取 Session lifecycle,通过 `useProjection(key, selector)` 读取领域投影,使每个 hook 都隔离无关更新。
 - **SessionManager**(manager.ts):实例簇 + 帧总入口 + 会话列表。带 sessionId 的帧只投已存在实例(mux 广播不得把每个会话都实例化);例外是审批/问答 `requested` 帧——它们不落 history、open 无法回补,故缓冲进 `pendingBuffers`,实例化时回放。
 - **Notifier**(notifier.ts):两条通知通道,按变更来源取用。`markDirty()`(默认;帧驱动一律用它)按微任务合批——N 次变更、一次通知、一次重渲染;flush 先重建快照缓存再通知。`notifyNow()`(仅用户手势的直接回响)同 tick 重建并通知——受控输入的回响若延到微任务,DOM 会回滚、光标跳尾。帧驱动代码用 notifyNow 会让合批塌回逐帧渲染;禁。
 - **ConversationNodeAssembler**(`runtime/src/client/conversation/`):Session 拥有的增量引擎在原始事件上运行各自独立注册的 Definition。`match(event)` 无须扫描 Context 即可选出 `(kind, id)`;start/update 构造 Definition state;引擎计算的 Location 携带 Turn/Step 关闭信息;向前查询 Context 时记录依赖,并由后续 prepend 修复;`buildViewNode(target)` 只物化 dirty Context。Chat builder 保留结构顺序和 per-key value identity,`useSession` selector 负责消费隔离,Assistant token 发布则合并到每个 animation frame 一次。[Conversation Node 决策](2026-08-09-client-conversation-node-assembly.zh.md)拥有组装边界,[Tool 展示所有权](2026-08-08-client-tool-presentation-ownership.zh.md)拥有 Tool 递归渲染。

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml

@@ -2,5 +2,5 @@
 # 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/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md
-2026-07-25-web-client-session-scope-and-provide-channel.md: 6f159dc16e0e4063f9077c31829a10caca98eae0
-2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 2cb082dce75f1845e52a52289a8e3eaa1a000931
+2026-07-25-web-client-session-scope-and-provide-channel.md: 49b02fe3776f4cac8080ab527f5958baca3ef7ab
+2026-07-25-web-client-session-scope-and-provide-channel.zh.md: e1a8427be2c02bb01a1b7a80d723b78e9ca4c7d9

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md

@@ -99,7 +99,7 @@ Slot scope is the closed set `root | session-maybe | session`:
 - Concurrent discipline: the render plane reads only from the hooks compartment (uSES consistency guarantee); props-compartment callbacks are used only in event-handler space; descriptor resolution is render-safe (idempotent caching, with prune reaping residue from abandoned renders).
 - Third-party components take zero value dependencies; types are a one-line type-only import (declaration merging into `SessionStandardProps` / `SessionMaybeStandardProps`).
 
-### The read-only queue mirror
+### Input delivery
 
 - Queue semantics: running does not lock input; ordinary messages queue through `session.prompt {mode:'queue'}`, and commands never queue.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md

@@ -99,7 +99,7 @@ slot scope 是闭集 `root | session-maybe | session`:
 - Concurrent 纪律:渲染平面只从 hooks 格读(uSES 一致性保证);props 格回调只在事件 handler 空间用;描述符解析 render-safe(幂等缓存、废弃渲染残留由 prune 收尸)。
 - 第三方组件值零依赖,类型一行 type-only import(declaration merging 进 `SessionStandardProps` / `SessionMaybeStandardProps`)。
 
-### 队列只读镜像
+### 输入投递
 
 - 队列语义:running 不锁输入;普通消息经 `session.prompt {mode:'queue'}` 排队,命令永不排队。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.i18n.yaml

@@ -2,5 +2,5 @@
 # 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/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md
-2026-07-31-claimed-pre-step-inbox-lifecycle.md: d914b075607181a8b036ede003e2998f18c6be3b
-2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md: 1701b949500c310656f48b9872e1d714a984658a
+2026-07-31-claimed-pre-step-inbox-lifecycle.md: 920237c7191b6d1c1861737073c6e41935cd6633
+2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md: 6433240be9587b137c649bb9b420a001f088a6f0

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md

@@ -18,7 +18,7 @@ Before every proposed step, `Inbox.claim(target)` atomically removes the complet
 
 The durable inbox remains two `UserMessage[]` lists addressed by `MessageId`. `append`, `prepend`, and `splice` take a target, while `replace(messageId, newMessage)` and `remove(messageId)` locate the pending message across both lists before committing a normalized splice. Replacement may change identity and emits the old message as discarded followed by the new message as inserted. Every insertion emits `agent/inbox/inserted { message }`; an ordinary removal records `outcome: 'canceled'` and emits `agent/inbox/discarded { message }`. Claiming records pure deletions without an outcome and emits claimed events from Inbox itself. These live events add no placement, outcome, or batch fields.
 
-The two event surfaces have separate consumers. Observers following one message use `agent/inbox/inserted`, `claimed`, and `discarded`. `AgentRegistry` contributes the standard `inbox` projection over the durable `agent/inbox/spliced` stream whenever the projection registry is composed; UI edits and removals route through an Inbox mutation method so the same projection records every change. When that projection reconstructs durable history, it rejects unsafe or out-of-range coordinates and duplicate `MessageId` values across both lists, and reports the offending event seq. Whole-queue control consumers use the projection change feed: the Session controller publishes the projection frame, then derives the queue replacement from the same post-fold inbox value.
+The two event surfaces have separate consumers. Observers following one message use `agent/inbox/inserted`, `claimed`, and `discarded`. `AgentRegistry` contributes the standard `inbox` projection over the durable `agent/inbox/spliced` stream whenever the projection registry is composed; UI edits and removals route through an Inbox mutation method so the same projection records every change. When that projection reconstructs durable history, it rejects unsafe or out-of-range coordinates and duplicate `MessageId` values across both lists, and reports the offending event seq. Whole-queue UI consumers read the same raw projection through the generic Session projection carrier; no derived queue value or dedicated control frame participates.
 
 Plugins that need current-step atomic rewriting return messages from `agent/pre-step`. Plugins that only need later context may mutate `agent.inbox` directly. Workspace context uses both paths: asynchronous filesystem projections stage one replaceable `next-step` item, while the next entering pre-step folds that item or a newly composed baseline into its final batch and removes the pending copy. Rejection keeps the item queued.
 
@@ -34,7 +34,7 @@ The archived [addressable queue occurrence decision](../../archived/feature/2026
 
 ## Verification
 
-Agent-loop coverage pins turn-start-before-claim-before-pre-step ordering, exact live event payloads, balanced no-step rejection, final-batch rewriting, input inserted after a claim, listener failure, and cancellation. Inbox and consumer tests pin pure claim deletions, canceled ordinary removals, agent-instructions staging, replacement, and same-step entry, plan/goal/hook behavior, UI cleanup, compaction, checkpointing, resumed durable projection, rejection of invalid persisted coordinates or cross-list identities, and post-fold queue replacement when the controller registers before the projection registry. Generated event and type catalogs expose only the new waterfall and payloads.
+Agent-loop coverage pins turn-start-before-claim-before-pre-step ordering, exact live event payloads, balanced no-step rejection, final-batch rewriting, input inserted after a claim, listener failure, and cancellation. Inbox and consumer tests pin pure claim deletions, canceled ordinary removals, agent-instructions staging, replacement, and same-step entry, plan/goal/hook behavior, UI cleanup, compaction, checkpointing, resumed durable projection, rejection of invalid persisted coordinates or cross-list identities, and generic client delivery of the folded Inbox value. Generated event and type catalogs expose only the new waterfall and payloads.
 
 ## Consequences
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md

@@ -18,7 +18,7 @@ Status: implemented
 
 持久 inbox 仍是两份通过 `MessageId` 寻址的 `UserMessage[]` 列表。`append`、`prepend` 与 `splice` 接受 target;`replace(messageId, newMessage)` 与 `remove(messageId)` 则在提交规范化 splice 前,通过 `MessageId` 跨两份列表定位待处理消息。替换可以改变标识,并先将旧消息作为 discarded 发布,再将新消息作为 inserted 发布。每次插入发出 `agent/inbox/inserted { message }`;普通删除记录 `outcome: 'canceled'` 并发出 `agent/inbox/discarded { message }`。领取记录不带 outcome 的纯删除,并由 Inbox 自行发出 claimed 事件。这些实时事件不增加 placement、outcome 或批次字段。
 
-两类事件接口服务不同消费方。跟踪单条消息的观察方使用 `agent/inbox/inserted`、`claimed` 与 `discarded`。`AgentRegistry` 会在投影注册表已组合时,在持久 `agent/inbox/spliced` 流上贡献标准 `inbox` 投影;UI 编辑与移除通过 Inbox 变更方法处理,从而让同一投影记录所有变化。该投影重建持久历史时,会拒绝不安全或越界的坐标,以及跨两份列表重复的 `MessageId`,并报告出错事件的 seq。整体队列的 control 消费方使用投影变更流:Session controller 先发布 projection frame,再从同一份折叠后的 inbox 值派生 queue replacement
+两类事件接口服务不同消费方。跟踪单条消息的观察方使用 `agent/inbox/inserted`、`claimed` 与 `discarded`。`AgentRegistry` 会在投影注册表已组合时,在持久 `agent/inbox/spliced` 流上贡献标准 `inbox` 投影;UI 编辑与移除通过 Inbox 变更方法处理,从而让同一投影记录所有变化。该投影重建持久历史时,会拒绝不安全或越界的坐标,以及跨两份列表重复的 `MessageId`,并报告出错事件的 seq。需要整份队列的 UI 消费方通过通用 Session projection carrier 直接读取同一份原始投影,不会派生 queue 值,也没有专用 control frame
 
 必须对当前步骤进行原子改写的插件从 `agent/pre-step` 返回消息。只需要稍后上下文的插件可以直接修改 `agent.inbox`。Workspace context 同时使用两条路径:异步文件系统投影会暂存一条可替换的 `next-step` 消息,而下一次进入步骤的 pre-step 会把该消息或新组合的基线折入最终批次,并移除仍待处理的副本。reject 会让该条目继续排队。
 
@@ -34,7 +34,7 @@ Status: implemented
 
 ## 验证
 
-agent loop(智能体循环)覆盖固定先 `turn/start`、再领取、后 pre-step 的顺序、实时事件的确切载荷、边界平衡的无步骤 reject、最终批次改写、领取后插入的输入、监听器失败与取消。Inbox 和消费方测试固定纯领取删除、普通删除的 canceled 结果、agent-instructions 的暂存、替换与同一步骤进入、plan/goal/钩子行为、UI 清理、压缩(compaction)、检查点、恢复后的持久投影、对非法持久坐标或跨列表重复标识的拒绝,以及 controller 早于投影注册表注册时仍使用折叠后队列值。生成的事件与类型目录只公开新的 waterfall 与载荷。
+agent loop(智能体循环)覆盖固定先 `turn/start`、再领取、后 pre-step 的顺序、实时事件的确切载荷、边界平衡的无步骤 reject、最终批次改写、领取后插入的输入、监听器失败与取消。Inbox 和消费方测试固定纯领取删除、普通删除的 canceled 结果、agent-instructions 的暂存、替换与同一步骤进入、plan/goal/钩子行为、UI 清理、压缩(compaction)、检查点、恢复后的持久投影、对非法持久坐标或跨列表重复标识的拒绝,以及折叠后 Inbox 值经通用客户端传输投递。生成的事件与类型目录只公开新的 waterfall 与载荷。
 
 ## 后果
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml

@@ -2,5 +2,5 @@
 # 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/architecture/2026-08-18-session-history-and-event-transport.md
-2026-08-18-session-history-and-event-transport.md: 2dc5eac282ac8a9902a1d27371e1ae55cae82751
-2026-08-18-session-history-and-event-transport.zh.md: fc80aa7279b5f7d7ce80c401e7eb80d2b288c889
+2026-08-18-session-history-and-event-transport.md: 42c08caf49425695fc66556578afc6b815600d51
+2026-08-18-session-history-and-event-transport.zh.md: 2cbb2174da31e9af472ecb8b6acbea76a51c145e

+ 5 - 5
.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md

@@ -8,7 +8,7 @@ English | [中文](2026-08-18-session-history-and-event-transport.zh.md)
 
 The browser consumes three kinds of data with different lifecycles: persistable, paginated Session logs; process-local state that needs an opening baseline to converge after reconnect; and immediate notifications that need no replay.
 
-These kinds of data cannot share one recovery rule. Session logs have stable sequence numbers and persistence, so a cursor can fill gaps; queue, jobs, and Workspace lists need a complete snapshot to replace an old mirror; ordinary notifications only promise delivery within the current Connection generation.
+These kinds of data cannot share one recovery rule. Session logs have stable sequence numbers and persistence, so a cursor can fill gaps; jobs, projection values, and Workspace lists need a complete snapshot to replace an old mirror; ordinary notifications only promise delivery within the current Connection generation.
 
 Observing Session history, lists, and projections must allow cold reads. If transport performs a general Typert lookup whenever an argument contains a Session or Agent, opening a page, switching tabs, or reconnecting the network implicitly resumes an Agent, so observation gains execution side effects.
 
@@ -200,9 +200,9 @@ A terminal failure from the initial page, repair page, or follow enters the curr
 
 `session.control()` is a Host-wide snapshot stream. One browser can observe transient state for all current live Sessions without opening a journal for every transcript.
 
-Each generation emits a complete baseline first, followed by queue, jobs, and projection deltas. The baseline reads attached Agents and process-local registries without resuming cold Agents.
+Each generation emits a complete baseline first, followed by jobs and projection deltas. The baseline reads process-local registries and folded projection values without resuming cold Agents.
 
-Queue and jobs use complete replacement values and apply last-wins. Agent attach, detach, Session disposal, and owner disposal can all clear a stale mirror through an empty value or a new baseline.
+Jobs use complete replacement values and apply last-wins. Projection updates carry monotonically increasing revisions, while a new baseline replaces the complete projection map. Session and owner disposal clear stale mirrors.
 
 The original `approval/request` and `user-questions/request` events are forwardable waterfalls. If an Agent-scoped Client listener claims a request, it returns directly. If all delivered Clients call `next()`, the original Cordis waterfall continues to later Host listeners. Session control neither stores nor replays these requests.
 
@@ -309,7 +309,7 @@ API Proxy carries only independent business APIs it owns. Session, Workspace, Re
 
 **Split Session transport and Session commands into two public packages.** Both depend on Session address, Agent activation policy, subagent ownership, error mapping, and Client mount ordering. One public Controller preserves unified ownership while internal classes can evolve independently.
 
-**Move queue, jobs, projection, Workspace, and logs to ordinary `$on`.** Ordinary events have no reconnect baseline, cursor, or gap repair, so one missed delivery leaves permanently stale state. Only notifications that need no recovery, can be repaired by an independent query, or carry their own lifetime as a waterfall fit `$on`.
+**Move jobs, projections, Workspace, and logs to ordinary `$on`.** Ordinary events have no reconnect baseline, cursor, or gap repair, so one missed delivery leaves permanently stale state. Only notifications that need no recovery, can be repaired by an independent query, or carry their own lifetime as a waterfall fit `$on`.
 
 **Make every domain Controller inherit a page/follow/retry base class.** Session journals and Workspace snapshots have different opening, recovery, and ordering rules. Gateway's three compositional stream objects reuse transport lifecycle while domain adapters declare only their own frame semantics.
 
@@ -339,7 +339,7 @@ Connection tests pin missing, duplicate, and withdrawn generation sources; the r
 
 Session Host tests pin cold page/follow without increasing attached Agents, contiguous events reaching a cold follow after an explicit prompt, direct-subagent ownership, message-aligned pagination, and terminal-error projection.
 
-Session control tests pin baseline-first delivery, no cold-Session resume, attach/detach cleanup, queue and jobs replacement, and the projection watermark.
+Session control tests pin baseline-first delivery, no cold-Session resume, jobs replacement, and the projection watermark.
 
 Session Client tests pin one journal owner per Session, no writeback from stale open epochs, independent cancellation of control and journal, and retaining the published window during carrier retry.
 

+ 5 - 5
.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md

@@ -8,7 +8,7 @@ Status: implemented
 
 浏览器同时消费三类生命周期不同的数据:可持久化并分页的 Session 日志、需要 opening baseline 才能在重连后收敛的进程内状态,以及无需重放的即时通知。
 
-这三类数据不能共用一种恢复规则。Session 日志有稳定 seq 和 persistence,可以按 cursor 补齐缺口;queue、jobs、Workspace 列表等状态需要以完整 snapshot 替换旧镜像;普通通知只保证当前 Connection generation 内投递。
+这三类数据不能共用一种恢复规则。Session 日志有稳定 seq 和 persistence,可以按 cursor 补齐缺口;jobs、projection 值和 Workspace 列表等状态需要以完整 snapshot 替换旧镜像;普通通知只保证当前 Connection generation 内投递。
 
 观察 Session 历史、列表和投影必须允许冷读取。若 transport 因参数中出现 Session 或 Agent 就触发通用 Typert lookup,打开页面、切换标签或网络重连都会隐式恢复 Agent,观察操作因此产生执行副作用。
 
@@ -200,9 +200,9 @@ initial page、repair page 或 follow 的 terminal failure 进入当前 Session
 
 `session.control()` 是 Host 范围的 snapshot stream,一个浏览器可观察所有当前 live Session 的瞬态状态,而不必为每个 transcript 打开 journal。
 
-每个 generation 先发完整 baseline,再发 queue、jobs 与 projection 增量帧。baseline 读取 attached Agent 和进程内 registry,不恢复冷 Agent。
+每个 generation 先发完整 baseline,再发 jobs 与 projection 增量帧。baseline 读取进程内 registry 和已折叠的 projection 值,不恢复冷 Agent。
 
-queue 与 jobs 使用完整 replacement 值并按 last-wins 应用。Agent attach、detach、Session disposal 与 owner disposal 都能用空值或新 baseline 清除陈旧镜像。
+jobs 使用完整 replacement 值并按 last-wins 应用。Projection update 携带单调递增 revision,新 baseline 则替换完整 projection map。Session 与 owner disposal 会清理陈旧镜像。
 
 原始 `approval/request` 与 `user-questions/request` 是可转发 waterfall。若某个 Agent-scoped Client listener claim,请求直接返回;若所有已投递 Client 都调用 `next()`,原 Cordis waterfall 继续到后续 Host listener。Session control 不保存或重放这些请求。
 
@@ -309,7 +309,7 @@ API Proxy 只承接自身拥有的独立业务 API,不是 Session、Workspace
 
 **把 Session transport 与 Session commands 拆成两个公开包。** 两者共同依赖 Session address、Agent 激活策略、subagent ownership、错误映射和 Client 挂载顺序;一个公开 Controller 保持统一所有权,内部 class 仍可独立演化。
 
-**把 queue、jobs、projection、Workspace 与日志都改成普通 `$on`。** 普通事件没有 reconnect baseline、cursor 或 gap repair,漏掉一次推送就会留下永久陈旧状态;只有无需恢复、可由独立查询修复,或以 waterfall 本身持有请求生命周期的通知适合 `$on`。
+**把 jobs、projection、Workspace 与日志都改成普通 `$on`。** 普通事件没有 reconnect baseline、cursor 或 gap repair,漏掉一次推送就会留下永久陈旧状态;只有无需恢复、可由独立查询修复,或以 waterfall 本身持有请求生命周期的通知适合 `$on`。
 
 **让每个领域 Controller 继承一个 page/follow/retry 基类。** Session journal 与 Workspace snapshot 的 opening、恢复和排序规则不同;Gateway 的三个组合式 stream 对象复用 transport 生命周期,同时让领域 adapter 只声明自己的 frame 语义。
 
@@ -339,7 +339,7 @@ Connection 测试固定 generation source 缺失、重复注册、撤回、`$eve
 
 Session Host 测试固定 cold page/follow 不增加 attached Agent、显式 prompt 后 cold follow 收到连续事件、direct subagent ownership、message-aligned pagination 和终止错误投影。
 
-Session control 测试固定 baseline-first、冷 Session 不恢复、attach/detach 清理、queue 与 jobs replacement,以及 projection watermark。
+Session control 测试固定 baseline-first、冷 Session 不恢复、jobs replacement 与 projection watermark。
 
 Session Client 测试固定每 Session 单一 journal owner、旧 open epoch 不写回、control 与 journal 独立取消,以及 carrier retry 期间保留已发布窗口。
 

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.i18n.yaml

@@ -2,5 +2,5 @@
 # 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/bug-fix/2026-07-31-web-stop-preserves-queue.md
-2026-07-31-web-stop-preserves-queue.md: b8bba45009c126495a8b03ee36d6eb2cccac9a93
-2026-07-31-web-stop-preserves-queue.zh.md: af9c114fd06452e58322f2554a0f2ccd861c81c9
+2026-07-31-web-stop-preserves-queue.md: 5cf49326d606dbb621d1ba88724ecd06f6145645
+2026-07-31-web-stop-preserves-queue.zh.md: 9bbe2fb28a165d964d59ac2b553a7e9adf796d02

+ 3 - 3
.agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.md

@@ -8,13 +8,13 @@ English | [中文](2026-07-31-web-stop-preserves-queue.zh.md)
 
 The Web stop button reached `session.cancel`, which mapped to broad `agent.cancel({ kind: 'user' })`. During an active turn, ordinary composer submissions are already accepted as independently addressable Queue occurrences. Broad cancellation discarded every occurrence when the user intended to stop only the current generation, conflating turn interruption with the Queue's explicit delete operation.
 
-The browser cannot repair that loss by resending visible rows. It does not own their live `InboxItemId`, wake policy, or claim race, and a resend can duplicate work that the Host has already claimed.
+The browser cannot repair that loss by resending visible rows. It does not own their `MessageId` admission, wake policy, or claim race, and a resend can duplicate work that the Host has already claimed.
 
 ## Decision
 
-`session.cancel` is the Web Host API's active-turn stop for ordinary sessions. It rejects session-backed subagents with `agent-busy`; otherwise it calls `agent.cancel({ kind: 'user' }, { keepInbox: true })`, preserving pending inbox work while cooperatively aborting the current turn. The underlying option preserves queued and steering entries; the Web Queue projection continues to expose only queued entries.
+`session.cancel` is the Web Host API's active-turn stop for ordinary sessions. It rejects session-backed subagents with `agent-busy`; otherwise it calls `agent.cancel({ kind: 'user' }, { keepInbox: true })`, preserving pending Inbox work while cooperatively aborting the current turn. The standard `inbox` projection carries both lists; QueueDock reads only `next-turn`.
 
-The AgentLoop starts no concurrent replacement turn. It closes and flushes the interrupted turn, reaches cancellation quiescence, and then claims the next waking queued occurrence through its existing FIFO driver. That claim emits `agent/inbox/dequeue`, so the Host's authoritative `session/queue` snapshot retires the claimed row and leaves the remaining tail visible. The browser neither resends nor promotes any row. Work that ignores cancellation delays this handoff until it settles.
+The AgentLoop starts no concurrent replacement turn. It closes and flushes the interrupted turn, reaches cancellation quiescence, and then claims the next waking queued message through its existing FIFO driver. That claim records a durable pure-deletion `agent/inbox/spliced` event, so the standard `inbox` projection retires the claimed row and leaves the remaining tail visible. The browser neither resends nor promotes any row. Work that ignores cancellation delays this transition until it settles.
 
 This mapping changes only the Host `session.cancel` endpoint used by Web clients. The default `Agent.cancel()` contract remains broad, ACP and TUI retain their existing cancellation policies, and `AgentHandle.dispose()` still clears pending work during teardown. Queue row removal remains the explicit Web action for discarding one pending occurrence.
 

+ 3 - 3
.agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.zh.md

@@ -8,13 +8,13 @@ Status: implemented
 
 Web 停止按钮调用 `session.cancel`,后者映射到广义 `agent.cancel({ kind: 'user' })`。在活动轮次期间,普通 composer 提交已经被接纳为可独立寻址的 Queue 入队项。用户只想停止当前生成时,广义取消却会丢弃所有入队项,混淆了轮次中断与 Queue 的显式删除操作。
 
-浏览器无法通过重发可见行修复这一损失。它不拥有这些行的实时 `InboxItemId`、唤醒策略或认领竞态;重发还可能重复 Host 已认领的工作。
+浏览器无法通过重发可见行修复这一损失。它不拥有这些行的 `MessageId` 准入、唤醒策略或认领竞态;重发还可能重复 Host 已认领的工作。
 
 ## 决策
 
-`session.cancel` 是 Web Host API 面向普通会话的活动轮次停止操作。它会以 `agent-busy` 拒绝由会话支撑的 subagent;否则会调用 `agent.cancel({ kind: 'user' }, { keepInbox: true })`,在协作式中止当前轮次的同时保留待处理 inbox 工作。底层选项会保留 queued 和 steering 入队项;Web Queue 投影继续只暴露 queued 入队项
+`session.cancel` 是 Web Host API 面向普通会话的活动轮次停止操作。它会以 `agent-busy` 拒绝由会话支撑的 subagent;否则会调用 `agent.cancel({ kind: 'user' }, { keepInbox: true })`,在协作式中止当前轮次的同时保留待处理 Inbox 工作。标准 `inbox` 投影携带两份列表;QueueDock 只读取 `next-turn`
 
-AgentLoop 不会启动并发的替代轮次。它会关闭并 flush 被中断的轮次,达到取消的完全停稳,然后通过现有 FIFO 驱动器认领下一个可唤醒的 queued 入队项。该认领会发出 `agent/inbox/dequeue`,因此 Host 的权威 `session/queue` 快照会退役已认领行,并使剩余队尾保持可见。浏览器既不重发,也不提升任何行。忽略取消的工作会延迟这一交接,直到该工作结算。
+AgentLoop 不会启动并发的替代轮次。它会关闭并 flush 被中断的轮次,达到取消的完全停稳,然后通过现有 FIFO 驱动器认领下一条可唤醒的 queued 消息。该认领会记录一条持久纯删除 `agent/inbox/spliced` 事件,因此标准 `inbox` 投影会退役已认领行,并使剩余队尾保持可见。浏览器既不重发,也不提升任何行。忽略取消的工作会延迟这一转换,直到该工作结算。
 
 该映射只更改 Web 客户端使用的 Host `session.cancel` 端点。`Agent.cancel()` 默认约定仍为广义取消,ACP 和 TUI 保留既有取消策略,`AgentHandle.dispose()` 在拆卸期间仍会清除待处理工作。移除 Queue 行仍是用于丢弃单个待处理入队项的显式 Web 操作。
 

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.i18n.yaml

@@ -2,5 +2,5 @@
 # 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/bug-fix/2026-08-17-durable-web-queue-recovery.md
-2026-08-17-durable-web-queue-recovery.md: 8bfdeaa46d7b2854c3e78848216c05e0688f9d15
-2026-08-17-durable-web-queue-recovery.zh.md: d75789430abcc638a779db882d7491373805e583
+2026-08-17-durable-web-queue-recovery.md: feea3b9c02fe5ce81bb6a8a453e83db658616ecd
+2026-08-17-durable-web-queue-recovery.zh.md: 35c5bea725bb632a587aba407535192ccb5eca29

+ 28 - 8
.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md

@@ -1,4 +1,4 @@
-# Agent Note: Resume cold sessions for Inbox commands
+# Agent Note: Recover the Web queue from durable Inbox state
 
 Status: implemented
 
@@ -6,24 +6,44 @@ English | [中文](2026-08-17-durable-web-queue-recovery.zh.md)
 
 ## Problem
 
-Inbox state survives in the session log. An implementation that looks up only a live Agent cannot reach the restored pending rows of an ordinary persisted Session after a Host restart, so editing or removing one returns `queue-item-not-found` even though persistence still owns the queue occurrence.
+Inbox acceptance records normalized `agent/inbox/spliced` events, but the Web queue used a separate mux baseline built by enumerating live Agents. After a Host process restart, a persisted ordinary Session remained cold until an operation needed its Agent, so the live-only baseline omitted accepted pending messages that were still present in the durable log.
+
+A reconnect-only repair would retain two recovery implementations: one for a live Inbox and one for cold Web reads. The correct owner is the Inbox domain, and the session-projection framework already provides live drive, cold folding, reconnect baselines, and cache restoration.
 
 ## Decision
 
-`session.updateQueue` resolves an ordinary cold Session through the shared Agent resolver before reading or mutating its Inbox. A missing persisted Session — including in a deployment that composes no persistence backend — still maps to `queue-item-not-found`, while other resume failures keep their existing error and subagent ownership keeps the same fence as other Agent operations.
+`AgentRegistry` registers the standard `inbox` projection whenever the Session projection registry is composed. The projection folds normalized `agent/inbox/spliced` operations from the complete session log into the complete `{ 'next-turn', 'next-step' }` state and exposes that same state as its value. A fork therefore projects pending Inbox operations inherited in its seed, like every other projection. Its schema and `InboxState` type have one definition; message values rely on the existing typed `UserMessage` contract rather than a second runtime message validator.
+
+The projection registry owns both durable reconstruction and live state. Its existing `session/event` drive folds each committed splice before `Session.append()` returns. Each Agent's `Inbox` command facade reads that same registry state. Inbox normalizes splice coordinates and checks pending `MessageId` uniqueness before append. It does not traverse `session.events`, copy the folded arrays, or apply the committed transition again. It emits the per-message `inserted`, `discarded`, and `claimed` live notifications owned by its mutations.
+
+The generic session-projection carrier is the only Web transport. It sends higher-seq `session/projection` values, includes the complete values block on history tail pages, folds detached cold logs, and uses the projection cache when valid. There is no Host-owned `queue` projection, placement vocabulary, handoff list, dedicated queue frame, or live-Agent reconnect enumeration.
+
+The client Session binding retains `inbox` in its generic per-session projection store and does not copy it into `SessionSnapshot`. QueueDock reads `next-turn` directly. ChatView reads user-origin `next-step` messages directly and ignores injected context. Claiming removes a pending value through the durable splice; a later `user/message` is rendered through the ordinary conversation projection.
+
+`session.updateQueue` resolves an ordinary cold Session through the shared Agent resolver before mutating its Inbox. A restored pending row therefore remains editable, removable, or steerable after restart, while subagent ownership keeps the same fence as other Agent operations.
 
-The resolved Agent constructs its Inbox from the registered durable projection. The command therefore reads the restored pending lists and records edits or removals through the existing normalized `agent/inbox/spliced` event. No new session event or on-disk format is introduced.
+No new session event or on-disk format is introduced. The existing splice stream remains the durable source of truth.
 
 ## Verification
 
-A cold-operation test provides a detached persisted Session with a pending Inbox splice, invokes `session.updateQueue`, and proves that the Session is resumed, the row is removed, and the durable removal splice is appended. The shipped keyless Web queue-actions snapshot covers user-visible editing and removal through the real HTTP/SSE path; its output is unchanged, while the Host test isolates the cold lifecycle branch.
+Inbox tests prove that service creation restores both lists through the registered projection, a direct durable append is immediately visible through the same live cell, a fork projects pending input inherited in its seed, and Inbox mutations reject duplicate pending identities before append. Host projection coverage reads a detached persisted Session with a pending splice, returns `values.inbox` through `session.history`, and proves that no live Agent is required. A separate cold-operation test proves `session.updateQueue` resumes the Session and appends the durable removal splice.
+
+Client coverage pins generic Inbox projection delivery, reconnect truncation, higher-seq retention before Session materialization, and the absence of queue state from `SessionSnapshot`. UI coverage pins direct `next-turn` QueueDock rendering and user-origin `next-step` ChatView rendering. The keyless Web fixture opens a cold persisted Session and observes its pending row after restart.
 
 ## Alternatives considered
 
-**Treat every missing live Agent as a missing queue item.** Rejected because persistence may still own the ordinary Session and its durable Inbox projection.
+**Add cold Sessions to the old queue reconnect loop.** Rejected because it would duplicate the projection registry's cold fold and preserve separate implementations for live pushes, history, cache, and reconnect.
+
+**Register a Web-specific `queue` projection in Session Controller.** Rejected because pending input belongs to Inbox. Placement rows and a handoff list would introduce a second domain model solely for one client.
+
+**Store a complete Inbox snapshot on every splice event.** Rejected because the durable event is a normalized mutation, not a repeated aggregate. The projection framework owns aggregate reconstruction and checkpointing.
 
-**Fold the session log inside `session.updateQueue`.** Rejected because the Inbox projection already owns reconstruction, while the shared Agent resolver owns cold lifecycle setup and preset composition.
+**Reconstruct Inbox in the client from raw session events.** Rejected because pagination may omit the insertion that established current state and every client would duplicate splice semantics.
+
+**Resume every cold Agent while opening the mux stream.** Rejected because displaying durable state must not publish runtime resources, mount presets, or start lifecycle work.
 
 ## Consequences
 
-Operations on restored Inbox rows use the same preset composition, ownership checks, and durable mutation path as operations on live rows. Reading durable state does not itself require eager Agent recovery; only an explicit command resumes the ordinary Agent.
+Pending Queue and steering input recover after Host process restart without resuming an Agent. Live Inbox reads, cold history, reconnect, and projection caching use the same domain-owned fold and registry state. Operations on a restored row do resume its ordinary Agent, preserving preset composition and ownership checks.
+
+Clients receive the raw two-list Inbox value and decide which messages their surface presents. Forked sessions project pending Inbox operations present in their inherited seed. The projection state version invalidates cached rows whenever its serialized state or fold semantics change.

+ 28 - 8
.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.zh.md

@@ -1,4 +1,4 @@
-# Agent Note: 为 Inbox 命令恢复冷会话
+# Agent Note: 从持久 Inbox 状态恢复 Web Queue
 
 Status: implemented
 
@@ -6,24 +6,44 @@ Status: implemented
 
 ## 问题
 
-Inbox 状态保存在会话日志中。若实现只查找 live Agent,Host 重启后就无法访问普通持久 Session 中已恢复的待处理行,因此即使持久层仍拥有该 queue occurrence,编辑或移除操作也会返回 `queue-item-not-found`。
+Inbox 接受消息时会记录规范化的 `agent/inbox/spliced` 事件,但 Web Queue 使用另一份通过枚举 live Agent 构建的 mux 基线。Host 进程重启后,持久化的普通 Session 会保持冷状态,直到某项操作需要其 Agent,因此 live-only 基线会遗漏仍存在于持久日志中的已接受待处理消息。
+
+只修复重连逻辑仍会保留两套恢复实现:一套用于 live Inbox,另一套用于 Web 冷读取。正确的所有者是 Inbox 领域,而会话投影框架已经提供 live 驱动、冷折叠、重连基线和缓存恢复。
 
 ## 决策
 
-`session.updateQueue` 在读取或修改 Inbox 前,通过共享 Agent 解析器解析普通冷 Session。持久 Session 确实不存在时(包括未组装持久化后端的部署)仍映射为 `queue-item-not-found`;其他恢复失败保留原有错误,subagent ownership 也保持与其他 Agent 操作相同的限制。
+`AgentRegistry` 会在 Session projection registry 已组合时注册标准 `inbox` 投影。该投影从完整会话日志中折叠规范化的 `agent/inbox/spliced` 操作,得到完整的 `{ 'next-turn', 'next-step' }` 状态,并直接暴露同一状态。因此 fork 会像其他投影一样,自然投影其 seed 中继承的待处理 Inbox 操作。其 schema 与 `InboxState` 类型只有一个定义;消息值依赖既有的类型化 `UserMessage` 约定,而不增加第二套运行时消息校验器。
+
+投影注册表同时拥有持久重建与 live 状态。其既有的 `session/event` 驱动会在 `Session.append()` 返回前折叠每个已提交 splice。每个 Agent 的 `Inbox` 命令 facade 都读取同一份注册表状态。Inbox 在 append 前规范化 splice 坐标并检查待处理 `MessageId` 唯一性。Inbox 不遍历 `session.events`,不复制已折叠数组,也不再次应用已提交 transition。它仍发出其变更拥有的逐消息 `inserted`、`discarded` 与 `claimed` live 通知。
+
+通用会话投影传输层是唯一 Web 传输。它发送 seq 更高的 `session/projection` 值,在历史尾页中包含完整 values 块,折叠已分离的冷日志,并在缓存有效时使用投影缓存。系统不存在 Host 拥有的 `queue` 投影、placement 词汇、handoff 列表、专用 queue 帧或枚举 live Agent 的重连逻辑。
+
+客户端 Session binding 在通用逐会话投影存储中保留 `inbox`,不会把它复制进 `SessionSnapshot`。QueueDock 直接读取 `next-turn`。ChatView 直接读取用户来源的 `next-step` 消息,并忽略注入上下文。认领操作通过持久 splice 移除待处理值;后续 `user/message` 由普通会话投影渲染。
+
+`session.updateQueue` 在修改 Inbox 前通过共享 Agent 解析器解析普通冷 Session。因此,恢复出的待处理行在重启后仍可编辑、移除或 steering,而 subagent ownership 保持与其他 Agent 操作相同的 fence。
 
-解析出的 Agent 从已注册的持久投影构建 Inbox。因此,该命令会读取恢复出的待处理列表,并通过既有的规范化 `agent/inbox/spliced` 事件记录编辑或移除。系统不引入新的会话事件或磁盘格式。
+系统没有引入新的会话事件或磁盘格式。既有 splice 流仍是持久真源
 
 ## 验证
 
-冷操作测试提供一份带待处理 Inbox splice 的分离持久 Session,调用 `session.updateQueue`,并证明 Session 会被恢复、待处理项会被移除且持久删除 splice 会被追加。既有 keyless Web queue-actions snapshot 通过真实 HTTP/SSE 路径覆盖用户可见的编辑与移除;其输出保持不变,Host 测试则隔离验证冷生命周期分支。
+Inbox 测试证明服务创建会通过已注册投影恢复两份列表、直接追加的持久事件会立即通过同一 live cell 可见、fork 会投影其 seed 中继承的待处理输入,并且 Inbox 变更会在 append 前拒绝重复的待处理标识。Host 投影覆盖会读取包含待处理 splice 的已分离持久 Session,经 `session.history` 返回 `values.inbox`,并证明不需要 live Agent。另一项冷操作测试证明 `session.updateQueue` 会恢复 Session 并追加持久删除 splice。
+
+客户端覆盖固定通用 Inbox 投影投递、重连截断、Session 实例化前保留 seq 更高的值,以及 `SessionSnapshot` 不含 queue 状态。UI 覆盖固定 QueueDock 直接渲染 `next-turn`,以及 ChatView 渲染用户来源的 `next-step`。无密钥 Web fixture 会打开一份冷持久 Session,并在重启后观察其待处理行。
 
 ## 考虑过的替代方案
 
-**把所有缺少 live Agent 的情况都当作队列项不存在。** 不予采纳,因为持久层可能仍拥有该普通 Session 及其持久 Inbox 投影。
+**把冷 Session 加入旧 queue 重连循环。** 不予采纳,因为这会重复投影注册表的冷折叠,并让实时推送、历史、缓存和重连继续使用不同实现。
+
+**在 Session Controller 注册 Web 专属 `queue` 投影。** 不予采纳,因为待处理输入属于 Inbox。placement 行与 handoff 列表会只为一个客户端引入第二套领域模型。
+
+**在每条 splice 事件中保存完整 Inbox 快照。** 不予采纳,因为持久事件是规范化变更,不是重复聚合。聚合重建与 checkpoint 属于投影框架。
 
-**在 `session.updateQueue` 内折叠会话日志。** 不予采纳,因为 Inbox 投影已经拥有重建逻辑,而共享 Agent 解析器拥有冷生命周期初始化和 preset 组合。
+**在客户端根据原始会话事件重建 Inbox。** 不予采纳,因为分页可能省略建立当前状态的插入事件,每个客户端也会重复实现 splice 语义。
+
+**打开 mux 流时恢复每个冷 Agent。** 不予采纳,因为展示持久状态不应发布运行时资源、挂载 preset 或启动生命周期工作。
 
 ## 后果
 
-对已恢复 Inbox 项的操作使用与 live 项相同的 preset 组合、所有权检查和持久变更路径。读取持久状态本身不要求提前恢复 Agent;只有显式命令会恢复普通 Agent。
+待处理 Queue 与 steering 输入可在 Host 进程重启后恢复,而无需恢复 Agent。live Inbox 读取、冷历史、重连与投影缓存使用同一份领域拥有的折叠与注册表状态。操作恢复出的行时会恢复其普通 Agent,从而保留 preset 组合与所有权检查。
+
+客户端接收原始的两列表 Inbox 值,并自行决定界面呈现哪些消息。fork 出的会话会投影继承 seed 中存在的待处理 Inbox 操作。投影的状态版本会在其序列化状态或折叠语义变化时使缓存行失效。

+ 2 - 2
.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml

@@ -2,5 +2,5 @@
 # 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-07-30-web-queue-steer-action.md
-2026-07-30-web-queue-steer-action.md: a112b875cd33422b5d22e3638afa8789155eb307
-2026-07-30-web-queue-steer-action.zh.md: ff0567e1b277df9658474b137f76c36c27d613ae
+2026-07-30-web-queue-steer-action.md: 4135c465166d88873e5b4595758778839efe83ee
+2026-07-30-web-queue-steer-action.zh.md: 2717c8a53b6a46c4751b1a8bb1e563b7aa37a42e

+ 11 - 15
.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md

@@ -16,27 +16,23 @@ Implementing the row action as a client-side delete followed by `session.prompt(
 
 Each non-editing ordinary-session QueueDock row exposes the upward-arrow action as “插话发送”. The action is enabled only while the session reports a running agent; mixed-content messages remain eligible because steering forwards the complete immutable `UserMessage` rather than the row's text projection. An addressed subagent keeps its Queue projection read-only because its continuation transport does not expose queue mutation.
 
-Activating the action requests strict current-turn steering for that exact `InboxItemId`. Success removes the Queue row through the authoritative Host snapshot and immediately projects the same pending steering after the `Deep diving...` running-status row; that bubble offers Copy but no Fork because the message has no durable event sequence yet. Once AgentLoop drains it, the existing durable `user/message` event takes over the same user-style bubble and restores its clock, Copy, and Fork without a separate durable presentation path.
+Activating the action requests current-turn steering for that exact `MessageId`. Success moves the message from `inbox['next-turn']` to `inbox['next-step']`; QueueDock removes its row and ChatView renders the same pending steering after the `Deep diving...` running-status row, with Copy but no Fork. Once AgentLoop drains it, the durable deletion removes the pending bubble and the existing `user/message` event enters the ordinary conversation-node path with its clock and Copy.
 
-The running bit is only an interaction hint. AgentLoop's `acceptsNextStep` value is authoritative at the synchronous mutation boundary. If that window has closed, the operation leaves the Queue occurrence unchanged and returns a typed `steer-unavailable` error, after which the original waking occurrence proceeds through Queue. If the driver already claimed the occurrence, it returns the existing `queue-item-not-found` error and independent-turn delivery is already underway. The UI treats both races as converged Queue delivery without a failure notice; transport and unknown errors still surface.
+The running bit is both the interaction hint and the Host's synchronous admission check. If the Agent is no longer running, the operation leaves the Queue message unchanged and returns a typed `steer-unavailable` error, after which the original waking message proceeds through Queue. If the driver already claimed the message, it returns the existing `queue-item-not-found` error and independent-turn delivery is already underway. The UI treats both races as converged Queue delivery without a failure notice; transport and unknown errors still surface.
 
 The composer uses a separate best-effort contract for newly typed input. While the addressed session is idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, a General Settings preference assigns plain Enter to Queue (the default) or Steer, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter inserts a newline. An addressed subagent keeps both gestures on its Queue-only continuation transport. The Host settings document persists the preference across Web origins sharing one DSH home, and it affects only the steer-capable busy-state gesture pair. If a direct composer Steer misses the current next-step window, AgentLoop automatically admits it as the next waking Queue turn and the Web does not report a failure.
 
 ### Agent and lifecycle boundary
 
-`InboxAction` gains a consumer-backed `{ kind: 'steer' }` operation alongside edit and remove. `Agent.updateInbox()` handles it only after locating the queued occurrence and proving `acceptsNextStep`; it never delegates to the best-effort `agent.steer()` alias.
-
-An applied action ends the queued occurrence and accepts the same immutable `UserMessage` as a new steering occurrence. The steering occurrence receives a new `InboxItemId` and truthful `placement: 'steering'`, while the message retains its `MessageId`, content, source, and any pending `SteeringReceipt` delivery controller. AgentLoop installs the new outbox entry before publishing lifecycle events, then emits its enqueue before the old occurrence's discard so re-entrant cancellation cannot observe or retire an unannounced item. The existing inbox conservation invariant therefore continues to require one enqueue and one terminal dequeue or discard for each occurrence.
-
-The action does not run `agent/prompt-submit`: choosing steering intentionally changes delivery from an independently admitted turn to current-turn next-step input. It neither cancels current work nor reorders the remaining Queue.
+`session.updateQueue` locates the `MessageId` in `Inbox.nextTurn` and requires the Agent to be running. It then removes that exact message and synchronously passes the same immutable `UserMessage` to `Agent.steer()`. The two Inbox mutations append a canceled `next-turn` deletion followed by a `next-step` insertion without an await or a second RPC, while preserving the message id, content, and source. Choosing steering changes delivery from an independently admitted turn to current-turn next-step input; it neither cancels current work nor reorders the remaining Queue.
 
 ### Host and client boundary
 
-`session.updateQueue` carries the `steer` action and maps the two negative outcomes to typed RPC errors. The conversion is one Agent operation; the Host never reconstructs it by combining remove and prompt calls.
+`session.updateQueue` carries the `steer` action and maps the two negative outcomes to typed RPC errors. The conversion is one synchronous pair of Inbox operations; the Host never reconstructs it by combining remove and prompt RPCs.
 
-The Host's existing `queuedMirror` remains the sole transient inbox authority. Its `session/queue` snapshot carries every live occurrence with `placement: 'queued' | 'steering'`: QueueDock renders only queued rows, while ChatView renders pending steering at the conversation tail after the `Deep diving...` running-status row, with Copy but without Fork, edit, or delete actions. Reconnect replays the same snapshot, so this visibility does not require client optimism or a second registry.
+AgentRegistry's standard `inbox` session projection is the sole Web pending-input authority. It folds durable `agent/inbox/spliced` events without consulting a live Agent and carries the raw `next-turn` and `next-step` lists. QueueDock renders `next-turn`, while ChatView renders user-origin `next-step` messages at the conversation tail after the `Deep diving...` running-status row, with Copy but without Fork, edit, or delete actions. The generic projection carrier supplies live pushes, history-tail reconnect baselines, cold-log folding, and process-restart recovery, so this visibility requires neither client optimism nor a live-only mirror.
 
-When AgentLoop claims pending steering, it emits `agent/inbox/dequeue` immediately before synchronously appending the durable `user/message`. The Host retires that steering row on the following microtask, allowing the durable session event to enter the linear mux stream first. On the accepted live event, the client Session retires the first matching current steering occurrence before publishing its snapshot; history replay does not consume a later occurrence that reused the same `MessageId`. ChatView therefore renders one authority at a time without scanning durable history, and the durable projection restores the clock, Copy, and Fork against its logged event time and sequence. An append failure still retires the claimed row.
+When AgentLoop claims pending steering, its pure deletion splice removes the message from `next-step`. The matching durable `user/message` then enters the ordinary conversation-node projection, which restores the clock and Copy against its logged event time and sequence. No client-side Inbox fold or projection-owned handoff list participates.
 
 The existing `session.prompt(mode: 'steer')` contract remains best-effort for new primary-session input: outside the next-step window it becomes a waking follow-up. The composer carries an explicit `queue | steer` mode through slash adjudication and reference serialization before calling that contract. A browser submission policy owns the live busy-Enter preference while the Host settings service owns durability; the policy resolves plain versus accelerated Enter as complementary gestures only for steer-capable sessions, and the Settings row and InputBar share it without duplicating storage or delivery-window authority. Only the Queue row action is strict, because either negative result converges through the original Queue occurrence.
 
@@ -44,7 +40,7 @@ The existing `session.prompt(mode: 'steer')` contract remains best-effort for ne
 
 AgentLoop contract coverage holds prompt admission open, converts one exact queued occurrence, and proves the replacement steering occurrence keeps the message value and delivery receipt, drains as a `user/message`, and never starts its former independent turn. It also pins unavailable-window retention, claimed-address rejection, and re-entrant cancellation lifecycle conservation.
 
-Host schema and proxy tests cover the new action, both typed errors, placement-aware snapshots and reconnect replay, plus durable-before-retirement ordering. Client tests cover silent convergence of both semantic races, genuine error reporting, read-only subagent rows and Queue-only subagent gestures. Runtime and ChatView tests cover occurrence-aware pending-to-durable handoff, including repeated `MessageId` values, while Web ARIA snapshots cover pending steering after the running-status row with Copy alone and the durable node with clock, Copy, and Fork.
+Host schema and proxy tests cover the new action, both typed errors, Inbox projection snapshots, reconnect replay, and durable deletion ordering. Client tests cover silent convergence of both semantic races, genuine error reporting, read-only subagent rows, and Queue-only subagent gestures. Runtime and ChatView tests cover direct pending Inbox rendering, while Web ARIA snapshots cover pending steering after the running-status row and the later durable node.
 
 The keyless Web steering scenario queues a message through the real composer while the first response streams, activates the row arrow, then uses `ask_user_question` as a stable pending-steering barrier. It proves the Host-backed pending bubble appears before admission, hands off to one durable interjection after the answer, and affects the next model request. Assembled composer scenarios prove default-mode Cmd+Enter reaches the same pending and durable path without creating a Queue row, while Steer-mode Cmd+Enter creates a Queue row instead. Settings and submission-policy coverage pin the default, persistence, busy-only scope, and complementary gesture mapping; Queue edit/delete scenarios continue to prove those actions are unchanged.
 
@@ -58,14 +54,14 @@ The keyless Web steering scenario queues a message through the real composer whi
 
 **Change `agent.steer()` to be strict for every caller.** Rejected because TUI and plugin callers use its safe follow-up fallback for newly submitted input. A queued row has recoverable state that those callers do not.
 
-**Preserve the same `InboxItemId` while changing placement.** Rejected because `InboxItemId` identifies one FIFO acceptance and `placement` records that acceptance's resolved delivery. Ending one queued occurrence and accepting one steering occurrence keeps lifecycle facts truthful and leaves the conservation invariant unchanged.
+**Mint a second row identity while changing placement.** Rejected because `MessageId` already addresses the durable pending message across both Inbox lists. Keeping it through the synchronous deletion and insertion lets one projection express the delivery change without an occurrence wrapper.
 
-**Add a dedicated pending-steering projection and client store.** Rejected because queued and steering occurrences already share one Agent inbox lifecycle and one Host mirror. A second projection would duplicate reconnect state and ordering authority; a placement tag lets each client surface select its rows without widening Queue mutation semantics.
+**Add a dedicated pending-steering projection and client store.** Rejected because queued and steering messages already share the standard durable `inbox` projection. Each client surface selects the owned list and message sources it presents without duplicating reconnect state or ordering authority.
 
 **Cancel the active turn and run the selected Queue item.** Rejected because it destroys unrelated in-flight work and starts a new turn rather than steering the current one.
 
 ## Consequences
 
-`session/queue` describes a placement-aware transient inbox snapshot rather than a Queue-only list, so every consumer must filter by placement. Pending steering survives reconnect and appears immediately, but remains non-durable until the durable `user/message` commits. The running bit can also remain true briefly after the strict next-step window closes, so an enabled action may internally return `steer-unavailable` while the product continues through Queue without reporting failure.
+`inbox` is the domain-owned durable session projection. Pending steering survives reconnect and process restart and appears immediately from `next-step`; after claim, the durable conversation node follows through the ordinary event projection. The running bit can change between rendering and the synchronous operation, so an enabled action may return `steer-unavailable` while the product continues through Queue without reporting failure.
 
-The explicit action changes delivery from an independently admitted turn to current-turn steering, so prompt-admission plugins do not process the converted message. Enqueue-before-discard lifecycle publication remains required for re-entrant cancellation safety; focused regression coverage protects that ordering.
+The explicit action changes delivery from an independently admitted turn to current-turn steering. Its two durable splices briefly expose the deletion and insertion as adjacent projection revisions, while client snapshot batching converges on the message moving from `next-turn` to `next-step`.

+ 11 - 15
.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md

@@ -16,27 +16,23 @@ Web composer 原本会在 agent(智能体)运行期间把所有 Enter 提交
 
 普通会话中每个非编辑态的 QueueDock 行都会提供名为「插话发送」的向上箭头操作。仅当会话报告 agent 正在运行时,该操作才会启用;包含混合内容的消息仍可使用,因为 steering 会转发完整且不可变的 `UserMessage`,而非该行的文本投影。已寻址 subagent 的 Queue 投影保持只读,因为其继续执行传输不提供 Queue 变更。
 
-触发该操作会针对对应的 `InboxItemId` 请求严格的当前轮次 steering。操作成功后,权威 Host 快照会移除 Queue 行,并在 `Deep diving...` 运行状态行之后立即投影同一条待处理 steering;该气泡提供复制,但消息尚无持久事件序号,因此不提供 fork。AgentLoop 排空该项后,现有持久 `user/message` 事件会接管同一个用户样式气泡,并恢复时钟、复制和 fork,无需另建持久展示路径
+触发该操作会针对对应的 `MessageId` 请求当前轮次 steering。操作成功后,消息会从 `inbox['next-turn']` 移入 `inbox['next-step']`;QueueDock 移除其行,ChatView 则在 `Deep diving...` 运行状态行之后渲染同一条待处理 steering,提供复制但不提供 fork。AgentLoop 排空该项后,持久删除会移除待处理气泡,现有 `user/message` 事件则进入普通 Conversation Node 路径,并携带时钟与复制操作
 
-running 标志位只用于提示交互状态。在同步变更边界上,AgentLoop 的 `acceptsNextStep` 值才是权威依据。如果该窗口已经关闭,操作会保持 Queue 单次入队项不变并返回类型化的 `steer-unavailable` 错误,随后原唤醒单次入队项会经 Queue 继续执行。如果驱动器已经认领该项,则返回现有的 `queue-item-not-found` 错误,且独立轮次投递已经开始。UI 会把两种竞态都视为已收敛的 Queue 投递,不显示失败通知;传输和未知错误仍会显示。
+running 标志位既是交互提示,也是 Host 的同步准入检查。如果 Agent 已不再运行,操作会保持 Queue 消息不变并返回类型化的 `steer-unavailable` 错误,随后原唤醒消息会经 Queue 继续执行。如果驱动器已经认领该消息,则返回现有的 `queue-item-not-found` 错误,且独立轮次投递已经开始。UI 会把两种竞态都视为已收敛的 Queue 投递,不显示失败通知;传输和未知错误仍会显示。
 
 Composer 对新输入采用另一套尽力而为约定。所寻址会话空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,General Settings 偏好会把普通 Enter 分配为 Queue(默认值)或 Steer,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 用于换行。已寻址 subagent 会让这两个手势都使用其仅支持 Queue 的继续执行传输。Host settings 文档会在共享同一 DSH home 的 Web origin 之间持久化该偏好,并且它只影响支持 steering 的繁忙态手势对。如果 composer 直接发出的 Steer 错过当前 next-step 窗口,AgentLoop 会自动将其接纳为下一条唤醒 Queue 轮次,Web 不显示失败。
 
 ### Agent 与生命周期边界
 
-`InboxAction` 会在编辑和移除之外,新增由实际消费方支撑的 `{ kind: 'steer' }` 操作。`Agent.updateInbox()` 只有在找到 queued 单次入队项并确认 `acceptsNextStep` 后才会处理该操作,绝不会委托给尽力而为的 `agent.steer()` 别名。
-
-操作成功应用后,系统会结束 queued 单次入队项,并把同一个不可变 `UserMessage` 接受为新的 steering 单次入队项。steering 单次入队项会获得新的 `InboxItemId` 和如实反映投递方式的 `placement: 'steering'`,消息则保留其 `MessageId`、内容、来源和任何待处理 `SteeringReceipt` 投递控制器。AgentLoop 会先安装新的 outbox 项,再发布生命周期事件;随后先发出新单次入队项的 enqueue,再发出旧单次入队项的 discard,确保可重入取消无法观察或退役一个尚未公布的项。因此,现有 inbox 守恒不变量仍然要求每个单次入队项恰好对应一个 enqueue,以及一个终态 dequeue 或 discard。
-
-该操作不会运行 `agent/prompt-submit`:选择 steering 会有意把投递方式从经独立接纳的轮次改为当前轮次的 next-step 输入。它既不会取消当前工作,也不会重新排序 Queue 中的剩余项。
+`session.updateQueue` 会在 `Inbox.nextTurn` 中定位 `MessageId`,并要求 Agent 正在运行。随后,它会移除对应消息,并同步把同一个不可变 `UserMessage` 传给 `Agent.steer()`。这两次 Inbox 变更会在没有 await 或第二次 RPC 的情况下,先追加一条 canceled `next-turn` 删除,再追加一条 `next-step` 插入,同时保留消息 id、内容与来源。选择 steering 会把投递方式从经独立接纳的轮次改为当前轮次的 next-step 输入;它既不会取消当前工作,也不会重新排序 Queue 中的剩余项。
 
 ### Host 与客户端边界
 
-`session.updateQueue` 会携带 `steer` 操作,并把两种负面结果映射为类型化 RPC 错误。这项转换是一次 Agent 操作;Host 绝不会通过组合移除和提示词调用来重建它。
+`session.updateQueue` 会携带 `steer` 操作,并把两种负面结果映射为类型化 RPC 错误。这项转换是一对同步 Inbox 操作;Host 绝不会通过组合移除与提示词 RPC 来重建它。
 
-Host 仍以现有 `queuedMirror` 作为唯一的瞬态 inbox 权威。`session/queue` 快照会携带所有存活单次入队项及其 `placement: 'queued' | 'steering'`:QueueDock 只渲染 queued 行,ChatView 则在会话流末尾、`Deep diving...` 运行状态行之后渲染待处理 steering,提供复制操作,但不提供 fork、编辑或删除操作。重连会重放同一份快照,因此这项可见性既不依赖客户端乐观展示,也不需要第二个注册表
+AgentRegistry 的标准 `inbox` 会话投影是唯一的 Web 待处理输入权威。它无需查询 live Agent 即可折叠持久 `agent/inbox/spliced` 事件,并携带原始 `next-turn` 与 `next-step` 列表。QueueDock 渲染 `next-turn`,ChatView 则在会话流末尾、`Deep diving...` 运行状态行之后渲染用户来源的 `next-step` 消息,提供复制操作,但不提供 fork、编辑或删除操作。通用投影传输层会提供实时推送、历史尾页的重连基线、冷日志折叠与进程重启恢复,因此这项可见性既不需要客户端乐观展示,也不需要仅适用于 live 状态的镜像
 
-AgentLoop 认领待处理 steering 时,会在同步追加持久 `user/message` 之前立即发出 `agent/inbox/dequeue`。Host 会等到下一个微任务才退役该 steering 行,让持久会话事件先进入线性 mux 流。客户端 Session 接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史回放不会消费后来复用同一 `MessageId` 的单次入队项。因此,ChatView 无需扫描持久历史就能每次只渲染一份权威,持久投影则会根据已记录的事件时间与序号恢复时钟、复制与 fork 操作。追加失败时,已认领行仍会退役
+AgentLoop 认领待处理 steering 时,对应纯删除 splice 会从 `next-step` 移除消息。匹配的持久 `user/message` 随后进入普通 Conversation Node 投影,并根据已记录的事件时间与序号恢复时钟和复制操作。客户端 Inbox 折叠或投影拥有的交接列表都不参与其中
 
 现有 `session.prompt(mode: 'steer')` 对主会话新输入仍采用尽力而为的约定:在 next-step 窗口之外,它会变为唤醒 agent 的后续轮次。Composer 会让显式 `queue | steer` 模式经过 slash 裁决与引用序列化,再调用该约定。浏览器提交策略拥有实时繁忙态 Enter 偏好,而 Host settings 服务拥有持久性;该策略只为支持 steering 的会话把普通 Enter 与加速 Enter 解析为互补手势,Settings 行和 InputBar 共享该策略,不重复实现存储或投递窗口权威。只有 Queue 行操作采用严格语义,因为任一种负面结果都会经原 Queue 单次入队项收敛。
 
@@ -44,7 +40,7 @@ AgentLoop 认领待处理 steering 时,会在同步追加持久 `user/message`
 
 AgentLoop 约定覆盖保持提示词接纳窗口打开,转换一个精确的 queued 单次入队项,并证明替代它的 steering 单次入队项保留消息值和投递回执、以 `user/message` 的形式排空,且绝不启动原本的独立轮次。该覆盖还钉住窗口不可用时保留原项、拒绝已被认领的地址,以及可重入取消下的生命周期守恒。
 
-Host schema 和代理测试覆盖新操作、两种类型化错误、带 placement 的快照与重连重放,以及先持久化再退役的顺序。客户端测试覆盖两种语义竞态的静默收敛、真实错误报告、只读 subagent 行和仅支持 Queue 的 subagent 手势。运行时与 ChatView 测试覆盖按单次入队项完成的待处理到持久交接,包括重复的 `MessageId` 值;Web ARIA 快照则覆盖位于运行状态行之后且仅有复制的待处理 steering,以及带时钟、复制和 fork 的持久节点。
+Host schema 和代理测试覆盖新操作、两种类型化错误、Inbox 投影快照、重连重放与持久删除顺序。客户端测试覆盖两种语义竞态的静默收敛、真实错误报告、只读 subagent 行和仅支持 Queue 的 subagent 手势。运行时与 ChatView 测试覆盖直接渲染待处理 Inbox;Web ARIA 快照则覆盖运行状态行之后的待处理 steering 与后续持久节点。
 
 无密钥 Web steering 场景在第一次响应流式输出期间,通过真实 composer 排队一条消息并触发行上的箭头,再用 `ask_user_question` 作为稳定的待处理 steering 屏障。该场景证明 Host 支撑的待处理气泡会在准入前出现,在回答后交接为唯一一条持久插话,并影响下一次模型请求。组装后的 composer 场景证明默认模式下的 Cmd+Enter 无需创建 Queue 行,也会进入同一条待处理与持久路径;Steer 模式下的 Cmd+Enter 则会创建 Queue 行。Settings 与提交策略覆盖会固定默认值、持久化、仅限繁忙态的作用域和互补手势映射;Queue 编辑/删除场景继续证明这些操作没有变化。
 
@@ -58,14 +54,14 @@ Host schema 和代理测试覆盖新操作、两种类型化错误、带 placeme
 
 **让每个调用方使用的 `agent.steer()` 都采用严格语义。** 不予采纳,因为 TUI 和插件调用方会针对新提交的输入使用其安全的后续轮次回退。queued 行具有这些调用方不具备的可恢复状态。
 
-**改变投递方式时保留同一个 `InboxItemId`。** 不予采纳,因为 `InboxItemId` 标识一次 FIFO 接受,而 `placement` 记录该次接受解析出的投递方式。结束一个 queued 单次入队项并接受一个 steering 单次入队项,能够使生命周期事实保持如实,并让守恒不变量保持不变
+**在改变 placement 时生成第二个行标识。** 不予采纳,因为 `MessageId` 已经可以跨两份 Inbox 列表寻址持久待处理消息。让同步删除与插入保持该标识,即可由同一份投影表达投递变化,无需单次出现包装层
 
-**增加专用的待处理 steering 投影和客户端 store。** 不予采纳,因为 queued 与 steering 单次入队项已经共享同一套 Agent inbox 生命周期和 Host mirror。第二份投影会重复保存重连状态与顺序权威;placement 标签能让各客户端界面选取自己的行,而不扩大 Queue 变更语义
+**增加专用的待处理 steering 投影和客户端 store。** 不予采纳,因为 queued 与 steering 消息已经共享标准持久 `inbox` 投影。各客户端界面选取自己呈现的列表与消息来源,无需重复重连状态或顺序权威
 
 **取消活动轮次并运行选中的 Queue 项。** 不予采纳,因为这会破坏无关的进行中工作,并且会启动新轮次,而不是 steering 当前轮次。
 
 ## 后果
 
-`session/queue` 表示带 placement 的瞬态 inbox 快照,而不只是 Queue 列表,因此每个消费方都必须按 placement 过滤。待处理 steering 会在界面中立即出现并能在重连后恢复,但在持久 `user/message` 提交前仍不持久。严格 next-step 窗口关闭后,running 标志位仍可能短暂保持为 true,因此已启用的操作可能会在内部返回 `steer-unavailable`,而产品仍经 Queue 继续执行且不显示失败。
+`inbox` 是领域拥有的持久会话投影。待处理 steering 会从 `next-step` 立即出现,能在重连与进程重启后恢复;认领后,持久会话节点经普通事件投影跟进。running 标志位可能在渲染与同步操作之间发生变化,因此已启用的操作可能返回 `steer-unavailable`,而产品仍经 Queue 继续执行且不显示失败。
 
-这项显式操作会把投递方式从经独立接纳的轮次改为当前轮次 steering,因此提示词接纳插件不会处理转换后的消息。为保证可重入取消安全,生命周期事件仍必须先发布 enqueue 再发布 discard;有针对性的回归覆盖会保护这一顺序
+这项显式操作会把投递方式从经独立接纳的轮次改为当前轮次 steering。它的两条持久 splice 会短暂地把删除与插入暴露为相邻投影修订,而客户端快照批处理会收敛到消息从 `next-turn` 移入 `next-step`

+ 2 - 2
.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.i18n.yaml

@@ -2,5 +2,5 @@
 # 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-06-web-queue-steer-all-gesture.md
-2026-08-06-web-queue-steer-all-gesture.md: c51e837d37b61a73f9602445daea9fbdfc77a299
-2026-08-06-web-queue-steer-all-gesture.zh.md: b0009a7a51ebe89f61d1a4b995b1138a7247d266
+2026-08-06-web-queue-steer-all-gesture.md: c068a5c2ce234353e6faf3971c1118774d666bfe
+2026-08-06-web-queue-steer-all-gesture.zh.md: eb91889ee5433765ce03013f81b0d9aedbc01fb6

+ 2 - 2
.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md

@@ -10,7 +10,7 @@ While a primary session runs, the Web queue accumulates messages the user typed
 
 ## Decision
 
-Empty-draft Cmd/Ctrl+Enter now steers every still-pending `queued`-placement inbox row into the running turn, in FIFO order, on a primary session that reports running. The gesture decodes in `InputBar.onKeyDown`: accelerated Enter with a trimmed-empty draft, `running`, no subagent address, and at least one `queued` row calls the new `ComposerKeyboard.steerQueue()` verb instead of `submit()`. `SessionInputShell.steerQueue()` delegates to a hub-wired choreography that re-reads the authoritative `session/queue` snapshot, filters `placement: 'queued'` (pending steering rows are already in the turn), and applies the queue dock's exact strict-steer operation — `session.updateQueue(itemId, { kind: 'steer' })` — sequentially, so FIFO ordering is guaranteed at the host. A `steer-unavailable` (turn closed mid-flush) or `queue-item-not-found` (row claimed meanwhile) converges silently; any other failure surfaces one composer notice (`插话发送失败,请重试。`). No wire, on-disk, or agent-loop change: the host already owns the strict-steer boundary.
+Empty-draft Cmd/Ctrl+Enter now steers every still-pending `next-turn` Inbox message into the running turn, in FIFO order, on a primary session that reports running. The gesture decodes in `InputBar.onKeyDown`: accelerated Enter with a trimmed-empty draft, `running`, no subagent address, and at least one queued message calls the new `ComposerKeyboard.steerQueue()` verb instead of `submit()`. `SessionInputShell.steerQueue()` delegates to a hub-wired choreography that re-reads `inbox['next-turn']` and applies the queue dock's exact strict-steer operation — `session.updateQueue(itemId, { kind: 'steer' })` — sequentially, so FIFO ordering is guaranteed at the host. A `steer-unavailable` (turn closed mid-flush) or `queue-item-not-found` (row claimed meanwhile) converges silently; any other failure surfaces one composer notice (`插话发送失败,请重试。`). No on-disk or agent-loop change: the host already owns the strict-steer operation.
 
 The gesture is strictly the accelerated chord. Plain Enter with an empty draft stays a no-op even under the busy-Enter Steer preference, draft content outranks the queue (accelerated Enter steers only the draft), and idle or subagent sessions keep the existing empty-draft no-op because steering has no live turn to enter.
 
@@ -30,4 +30,4 @@ The per-row 插话发送 action and its strict-steer boundary are owned by [Stee
 - **Steering via `session.prompt(mode: 'steer')` per row.** Rejected: that mints new messages instead of transferring the pending occurrences and would split the dock's immutable-message contract; `updateQueue({ kind: 'steer' })` already atomically transfers the exact occurrence.
 - **Firing all row steers concurrently.** Rejected: arrival order at the host is not guaranteed, and steering order is model-visible; sequential awaits preserve FIFO.
 - **A new host RPC for steer-all.** Rejected: the existing per-item operation is idempotent enough — each row is one strict steer, and mid-flush closure converges silently — so a protocol change buys nothing.
-- **A send-button tooltip.** Rejected: the primary button is Stop in the empty-draft running window, which is also the only window where the whole-queue gesture is available. The placeholder occupies that exact window and can describe the keyboard action directly.
+- **A send-button tooltip.** Rejected: the primary button is Stop while an ordinary session is running, which is the only window where the whole-queue gesture is available. The empty-draft placeholder occupies that exact window and can describe the keyboard action directly.

+ 2 - 2
.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md

@@ -10,7 +10,7 @@ Status: implemented
 
 ## 决策
 
-空草稿的 Cmd/Ctrl+Enter 现在会把仍在排队(`placement: 'queued'`)的 Inbox 行按 FIFO 顺序全部插话进运行中的轮次,仅限报告 running 的主会话。手势在 `InputBar.onKeyDown` 解码:加速 Enter + 去空白后为空草稿 + `running` + 无 subagent 地址 + 至少一条 `queued` 行时,改走新的 `ComposerKeyboard.steerQueue()` 动词而不是 `submit()`。`SessionInputShell.steerQueue()` 委托给 hub 编排的流程:重新读取权威的 `session/queue` 快照,过滤 `placement: 'queued'`(pending steering 行已经在本轮内),并逐条顺序执行 Queue 面板的严格 steer 操作 `session.updateQueue(itemId, { kind: 'steer' })`,从而在 host 侧保证 FIFO 顺序。`steer-unavailable`(flush 中途轮次关闭)或 `queue-item-not-found`(行已被占用)静默收敛;其他失败弹出一条 composer 通知(「插话发送失败,请重试。」)。无 wire、磁盘或 agent-loop 改动:严格 steer 边界本来就在 host 侧。
+空草稿的 Cmd/Ctrl+Enter 现在会把仍在 `next-turn` 的 Inbox 消息按 FIFO 顺序全部插话进运行中的轮次,仅限报告 running 的主会话。手势在 `InputBar.onKeyDown` 解码:加速 Enter + 去空白后为空草稿 + `running` + 无 subagent 地址 + 至少一条排队消息时,改走新的 `ComposerKeyboard.steerQueue()` 动词而不是 `submit()`。`SessionInputShell.steerQueue()` 委托给 hub 编排的流程:重新读取 `inbox['next-turn']`,并逐条顺序执行 Queue 面板的严格 steer 操作 `session.updateQueue(itemId, { kind: 'steer' })`,从而在 host 侧保证 FIFO 顺序。`steer-unavailable`(flush 中途轮次关闭)或 `queue-item-not-found`(行已被占用)静默收敛;其他失败弹出一条 composer 通知(「插话发送失败,请重试。」)。无磁盘或 agent-loop 改动:严格 steer 操作本来就在 host 侧。
 
 该手势严格限定为加速组合键。空草稿 + 普通 Enter 仍然无操作(即使 busy-Enter 偏好为 Steer);草稿内容优先于队列(加速 Enter 只插话当前草稿);idle 或 subagent 会话保持原有空草稿无操作,因为没有可插入的运行中轮次。
 
@@ -30,4 +30,4 @@ Status: implemented
 - **逐条用 `session.prompt(mode: 'steer')` 插话。** 已拒绝:那会铸造新消息而不是转移 pending 行,破坏 dock 的不可变消息契约;`updateQueue({ kind: 'steer' })` 已经原子地转移了确切的那条。
 - **并发触发所有行。** 已拒绝:host 到达顺序无法保证,而插话顺序对模型可见;顺序 await 保证 FIFO。
 - **为 steer-all 新增 host RPC。** 已拒绝:现有逐条操作已足够幂等——每行一次严格 steer,中途关闭静默收敛——协议改动没有收益。
-- **发送按钮 tooltip。** 已拒绝:主按钮在空草稿的运行窗口内是 Stop,这也是整队列手势唯一可用的窗口。placeholder 恰好在该窗口显示,可以直接说明这项键盘操作。
+- **发送按钮 tooltip。** 已拒绝:普通会话运行时,主按钮是 Stop,这也是整队列手势唯一可用的窗口。空草稿时的 placeholder 恰好在该窗口显示,可以直接说明这项键盘操作。

+ 2 - 2
.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml

@@ -2,5 +2,5 @@
 # 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-08-web-background-job-display.md
-2026-08-08-web-background-job-display.md: 8da6c2fd914bf07cfa7d3545cff1e42552c69d27
-2026-08-08-web-background-job-display.zh.md: 0e05ef9d2fcd8193c661f471b5f7b9a84891f98a
+2026-08-08-web-background-job-display.md: 4b8396c1c0504ddf8494a03c22b9af094c4eed17
+2026-08-08-web-background-job-display.zh.md: 03a43b87c47d0ef4d9c08d32facd91ceb76dda38

+ 1 - 1
.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md

@@ -81,7 +81,7 @@ Four rules the carrier keeps:
 
 `SessionListState` carries `jobsBySession: Readonly<Record<SessionId, readonly JobView[]>>`, owned by `SessionManager` and folded from the frame under last-wins, with an emptied set stored as an absent key so absence and `[]` are one representation.
 
-It lives on the list mirror rather than on `Session` for three reasons: the header action already reads list state through `useSessions`, nothing needs the pre-instantiation buffering `session/queue` requires (no composer behavior depends on tasks), and a later sidebar indicator gets the data without opening a second channel.
+It lives on the list mirror rather than on `Session` for three reasons: the header action already reads list state through `useSessions`, no composer behavior depends on tasks, and a later sidebar indicator gets the data without opening a second channel.
 
 Two replacement points keep it honest. Each control-stream generation clears the complete jobs mirror before installing the new baseline's non-empty sets. An `api-session/removed` event also drops that Session's entry, independently of the job-registry disposal notification's ordering.
 

+ 1 - 1
.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md

@@ -81,7 +81,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void
 
 `SessionListState` 带有 `jobsBySession: Readonly<Record<SessionId, readonly JobView[]>>`,由 `SessionManager` 拥有,按 last-wins 从帧折叠而来;被清空的集合存为缺失的键,使「缺失」与 `[]` 成为同一种表示。
 
-它放在列表镜像而不是 `Session` 上,有三个理由:header 入口本来就通过 `useSessions` 读列表状态;没有任何东西需要 `session/queue` 那种实例化前的缓冲(没有 composer 行为依赖任务;将来侧栏加指示器时不必再开第二条通道。
+它放在列表镜像而不是 `Session` 上,有三个理由:header 入口本来就通过 `useSessions` 读列表状态;没有 composer 行为依赖任务;将来侧栏加指示器时不必再开第二条通道。
 
 两个替换点让它保持诚实。每一代 control 流都会先清空完整任务镜像,再安装新 baseline 中的非空集合。`api-session/removed` 事件也会删除该 Session 的条目,不依赖任务注册表 disposal 通知与它之间的顺序。
 

+ 2 - 2
.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml

@@ -2,5 +2,5 @@
 # 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/proposed/architecture/2026-07-27-session-projection-and-command-log.md
-2026-07-27-session-projection-and-command-log.md: ab83d73983261f4f92fa4a88521855eb79865ef6
-2026-07-27-session-projection-and-command-log.zh.md: b8b6ec6fd673537d41609b5082af287cafad337b
+2026-07-27-session-projection-and-command-log.md: bf2a61f2bfea44e06a96cccf6aa7a56a4e4765d5
+2026-07-27-session-projection-and-command-log.zh.md: 900423a9893f50fbd30f4ff1f912ac202098ff32

+ 1 - 1
.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md

@@ -65,7 +65,7 @@ declare module 'cordis' {
 
 The registry's two read faces serve [subagent list identity via the projection unit](../../implemented/architecture/2026-08-06-subagent-list-identity-projection.md): a `subagent` unit folds durable mode/label identity last-wins from `subagent/descriptor`, and `SubagentRuntime.listChildren` reads it through `snapshot()` for a live child and `restore({}, events, 0)` over one persistence inspection for a cold one. An absent value remains that consumer's signal, and how absence renders is its own decision.
 
-The Agent registry contributes Inbox's operation-folding unit whenever the projection registry is composed. Like other units, it folds the complete session log, including inherited fork seed events, while preserving one projection state owner.
+The Agent registry contributes Inbox's operation-folding unit whenever the projection registry is composed, as recorded by [durable Web queue recovery](../../implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md). Like other units, it folds the complete session log, including inherited fork seed events, while preserving one projection state owner.
 
 ### Wire: projections block on the history tail page
 

+ 1 - 1
.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md

@@ -65,7 +65,7 @@ declare module 'cordis' {
 
 注册表的两处读取接口服务于[通过投影单元读取 subagent 列表身份](../../implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md):`subagent` 单元从 `subagent/descriptor` 按 last-wins 规则折叠持久化的 mode/label 身份,`SubagentRuntime.listChildren` 对 live child 通过 `snapshot()` 读取,对 cold child 则通过一次持久化检查结果调用 `restore({}, events, 0)` 读取。值缺席仍是该消费方的信号,如何呈现缺席仍由消费方自行决定。
 
-Agent 注册表会在投影注册表已组合时贡献 Inbox 的操作折叠单元。该单元与其他单元一样折叠完整会话日志,包括 fork 继承的 seed 事件,同时维持唯一的投影状态所有者。
+Agent 注册表会在投影注册表已组合时贡献 Inbox 的操作折叠单元,详见[持久化 Web Queue 恢复](../../implemented/bug-fix/2026-08-17-durable-web-queue-recovery.zh.md)。该单元与其他单元一样折叠完整会话日志,包括 fork 继承的 seed 事件,同时维持唯一的投影状态所有者。
 
 ### 协议层:历史尾页上的 projections 块
 

+ 2 - 2
.agents/notes/proposed/feature/2026-08-04-task-surface.i18n.yaml

@@ -2,5 +2,5 @@
 # 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/proposed/feature/2026-08-04-task-surface.md
-2026-08-04-task-surface.md: dbf73976b606a4a45202c1b29b79f5cfbd77ac1c
-2026-08-04-task-surface.zh.md: ecf8764b7b1da7a4a874dd78c6f69df80fb36f36
+2026-08-04-task-surface.md: 6bc8a60b1c0cf03bcdf40f8fbfeb4a3768b1fd42
+2026-08-04-task-surface.zh.md: 184adb537f23f57728d6accf229f3ff7727be4b6

+ 2 - 14
.agents/notes/proposed/feature/2026-08-04-task-surface.md

@@ -178,19 +178,7 @@ interface TaskSurfaceUserMessageSource {
 }
 ```
 
-The `session/queue` wire item already carries the complete `Message`. The client projection is explicitly extended to retain its source instead of dropping the correlation:
-
-```ts ignore-check
-interface QueuedMessage {
-  id: InboxItemId
-  messageId: MessageId
-  placement: 'queued' | 'steering'
-  source: MessageSource
-  content: readonly ContentBlock[]
-  preview: string
-  text: string | null
-}
-```
+The standard `inbox` projection already carries each complete `UserMessage`, including its `MessageId`, source, and content, in the raw `next-turn` or `next-step` list. The client retains that value in its generic projection store, so this proposal needs no queue transport extension or second pending-message type.
 
 The browser-safe domain package owns `TaskSurfaceId`, the submission and dismissal IDs, `TaskSurfaceCorrelation`, and the pending-submission shape. ApiProxy owns the transport augmentation that combines the correlation with `rpcId`. Keeping `kind: 'user'` preserves the ordinary user bubble and prompt semantics while the extra field provides durable correlation. The message content is a product-formatted readable summary: panel title, labels and submitted values, plus the optional note. The model receives that same text. The structured source is not a second hidden instruction.
 
@@ -198,7 +186,7 @@ The product shell owns collapse and dismiss. Collapse is local view state and se
 
 Submission is transactional at the client boundary. Acceptance returns the exact `messageId` in phase `queued`; the Dock disables every mutation through both `queued` and `claiming` and clears the persisted draft only after the matching user message becomes durable. A rejection keeps the values editable and shows the returned reason. Double clicks and transport retries reuse `submissionId` and return the first result; another submission ID receives `submission-pending` while the first is live. The Host admits one user message for one accepted Surface.
 
-The Task Surface service records accepted submission coordination as `pending.phase: 'queued'`, while the client can correlate the still-present queue row through its retained `source`. When the Agent dequeues that occurrence for ordinary prompt admission, the service synchronously changes the same pending record to `claiming` before ApiProxy publishes the ordinary queue snapshot without the claimed row. The service keeps that process-local claim across asynchronous admission and reconnect until a matching durable `user/message` is published or the Agent reports a terminal discard.
+The Task Surface service records accepted submission coordination as `pending.phase: 'queued'`, while the client can correlate the still-present queue row through its retained `source`. When the Agent claims that message for ordinary prompt admission, the service synchronously changes the same pending record to `claiming` before the durable deletion splice removes it from the generic `inbox` projection. The service keeps that process-local claim across asynchronous admission and reconnect until a matching durable `user/message` is published or the Agent reports a terminal discard.
 
 The matching `user/message` closes the durable projection and clears the claim. Rejection, cancellation, or disposal before durability reports the discard, clears the claim, and leaves the Surface open. The Dock never interprets queue-row disappearance as either outcome: it re-reads `getActive`; `pending.phase: 'claiming'` stays disabled, `pending: null` restores the draft, and `not-open` closes the Dock. `getActive` joins the log-derived active occurrence with this one process-local pending record. The record is coordination state, not a second durable authority; after a Host restart, an uncommitted claim is absent and the still-open logged Surface becomes editable again.
 

+ 2 - 14
.agents/notes/proposed/feature/2026-08-04-task-surface.zh.md

@@ -178,19 +178,7 @@ interface TaskSurfaceUserMessageSource {
 }
 ```
 
-`session/queue` 协议条目已经携带完整 `Message`。客户端投影会显式扩展以保留其来源,不再丢失关联信息:
-
-```ts ignore-check
-interface QueuedMessage {
-  id: InboxItemId
-  messageId: MessageId
-  placement: 'queued' | 'steering'
-  source: MessageSource
-  content: readonly ContentBlock[]
-  preview: string
-  text: string | null
-}
-```
+标准 `inbox` 投影已经在原始 `next-turn` 或 `next-step` 列表中携带每条完整 `UserMessage`,包括 `MessageId`、source 与 content。客户端会把该值保存在通用 projection store 中,因此本提案不需要扩展 queue 传输,也不需要第二种待处理消息类型。
 
 浏览器安全的领域包拥有 `TaskSurfaceId`、提交和关闭 ID、`TaskSurfaceCorrelation`,以及待处理提交的形态。ApiProxy 拥有传输扩展,负责将关联信息与 `rpcId` 组合。保留 `kind: 'user'` 可维持普通用户消息气泡和提示词语义,额外字段则提供持久关联信息。消息内容是由产品格式化的可读摘要,包括面板标题、标签和提交值,以及可选备注。模型接收相同的文本。结构化来源不是第二条隐藏指令。
 
@@ -198,7 +186,7 @@ interface QueuedMessage {
 
 客户端边界上的提交具有事务性。接纳成功会返回处于 `queued` 阶段的确切 `messageId`;在 `queued` 和 `claiming` 两个阶段中,Dock 会禁用所有变更,并且只有匹配的用户消息持久化后,才会清除已持久化的草稿。若请求被拒绝,则保留值供用户继续编辑,并显示返回的原因。双击和传输重试会复用 `submissionId` 并返回第一次调用的结果;只要第一次提交仍在处理中,另一个提交 ID 就会收到 `submission-pending`。对于一个已接受的 Surface,Host 只会接纳一条用户消息。
 
-Task Surface 服务将已接受提交的协调状态记录为 `pending.phase: 'queued'`,客户端则可通过仍在队列中的行所保留的 `source` 关联它。当 Agent 从队列取出该调用实例进行普通提示词接纳时,服务会先同步把同一份待处理记录改为 `claiming`,然后 ApiProxy 才发布不再包含已认领行的普通队列快照。服务会在异步接纳和重新连接期间一直保留这份进程内认领状态,直到匹配的持久 `user/message` 发布,或 Agent 报告终态丢弃。
+Task Surface 服务将已接受提交的协调状态记录为 `pending.phase: 'queued'`,客户端则可通过仍在队列中的行所保留的 `source` 关联它。当 Agent 为普通提示词接纳认领该消息时,服务会先同步把同一份待处理记录改为 `claiming`,随后持久删除 splice 才会从通用 `inbox` 投影移除该消息。服务会在异步接纳和重新连接期间一直保留这份进程内认领状态,直到匹配的持久 `user/message` 发布,或 Agent 报告终态丢弃。
 
 匹配的 `user/message` 会关闭持久投影并清除认领状态。在持久化之前发生拒绝、取消或 dispose(资源释放)时,系统会报告丢弃、清除认领状态,并让 Surface 保持打开。Dock 绝不会把队列行消失解读为其中任一结果,而会重新读取 `getActive`:`pending.phase: 'claiming'` 会维持禁用状态,`pending: null` 会恢复草稿,`not-open` 会关闭 Dock。`getActive` 会把由日志推导的活动调用实例与这唯一一份进程内待处理记录合并。该记录属于协调状态,不是第二个持久权威来源;Host 重启后,未提交的认领状态不复存在,日志中仍然打开的 Surface 会恢复为可编辑状态。
 

+ 21 - 1
apps/web/tests/cold-blank-session.e2e.ts

@@ -8,15 +8,18 @@ import { chromium } from 'playwright'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import {
   captureStableAria, compareOrRefreshGolden, launchWebScaffold, seedBlankSession,
-  watchConsole, webSnapshotMode, type WebScaffold,
+  seedQueuedSession, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
 import { newEnglishPage, saveFailureShot } from './support.ts'
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/cold-blank-session', import.meta.url))
 const SIDEBAR_EXPECTED = join(SNAPSHOT_DIR, 'sidebar.expected.md')
+const QUEUE_EXPECTED = join(SNAPSHOT_DIR, 'queue.expected.md')
 const MODE = webSnapshotMode()
 const SESSION_ID = 'cold-blank-session-web-e2e'
 const WORKSPACE_NAME = 'cold-blank-workspace'
+const QUEUED_SESSION_ID = 'cold-queue-recovery-web-e2e'
+const PENDING_TEXT = 'Accepted before the Host restarted'
 
 describe('web e2e: cold blank Session visibility', () => {
   let scaffold: WebScaffold
@@ -29,6 +32,8 @@ describe('web e2e: cold blank Session visibility', () => {
     const cwd = join(scaffold.workspaceCwd, WORKSPACE_NAME)
     await mkdir(cwd, { recursive: true })
     await seedBlankSession(scaffold, SESSION_ID, cwd)
+    const queuedSessionId = await seedQueuedSession(scaffold, QUEUED_SESSION_ID, PENDING_TEXT)
+    expect(scaffold.ctx.agents.get(queuedSessionId)).toBeUndefined()
     const header = (await scaffold.ctx.sessionPersistence.list())
       .find(candidate => candidate.id === SESSION_ID)
     if (header === undefined) throw new Error('blank Session fixture did not materialize')
@@ -57,4 +62,19 @@ describe('web e2e: cold blank Session visibility', () => {
     await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE)
     expect(tripwire.pageErrors).toEqual([])
   })
+
+  it('restores an accepted Queue row from the cold durable projection', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-cold-queue-recovery'))
+    const groupRow = page.locator('[role="treeitem"]').first()
+    await groupRow.click()
+    const sessionRow = page.locator('[role="treeitem"]').nth(1)
+    await sessionRow.waitFor({ timeout: 10_000 })
+    await sessionRow.click()
+
+    const dock = page.locator('[data-queue-dock]')
+    await dock.getByText(PENDING_TEXT, { exact: true }).waitFor({ timeout: 15_000 })
+    const snapshot = await captureStableAria(page, '[data-queue-dock]', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(QUEUE_EXPECTED, snapshot, MODE)
+    expect(tripwire.pageErrors).toEqual([])
+  })
 })

+ 9 - 0
apps/web/tests/expected/cold-blank-session/queue.expected.md

@@ -0,0 +1,9 @@
+- list:
+  - listitem:
+    - text: Accepted before the Host restarted
+    - button "Edit queued message":
+      - img
+    - button "Remove queued message":
+      - img
+    - button "Steer queued message" [disabled]:
+      - img

+ 4 - 1
apps/web/tests/expected/cold-blank-session/sidebar.expected.md

@@ -1 +1,4 @@
-- tree "Sessions": No sessions yet
+- tree "Sessions":
+  - treeitem "Ungrouped":
+    - img
+    - text: Ungrouped

+ 36 - 1
apps/web/tests/scaffold.ts

@@ -58,7 +58,7 @@ import {
 } from '@deepseek-ai/dsh-app-boot'
 import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
 import { settingsNamespace } from '@deepseek-ai/dsh-settings'
-import { LlmAdapter } from '@deepseek-ai/dsh-llm'
+import { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm'
 import type {
   LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, RetryPolicyConfig, StreamChunk,
 } from '@deepseek-ai/dsh-llm'
@@ -1064,6 +1064,41 @@ export async function seedBlankSession(
   return meta.id
 }
 
+/** Seed one detached non-blank Session with accepted next-turn input still pending. */
+export async function seedQueuedSession(
+  scaffold: WebScaffold,
+  id: string,
+  text: string,
+): Promise<SessionId> {
+  const meta: SessionHeader = {
+    version: SESSION_FORMAT_VERSION,
+    id: SessionId(id),
+    createdAt: Date.now() - 60_000,
+    cwd: scaffold.workspaceCwd,
+    delegationDepth: 0,
+  }
+  const pending = createUserMessage({
+    content: [{ type: 'text', text }],
+    source: { kind: 'user' },
+  })
+  await persistSeedSession(scaffold, meta, [
+    { type: 'turn/start', seq: 0, time: meta.createdAt, data: { turn: 1 } },
+    {
+      type: 'turn/end',
+      seq: 1,
+      time: meta.createdAt + 1,
+      data: { turn: 1, reason: { kind: 'blocked' } },
+    },
+    {
+      type: 'agent/inbox/spliced',
+      seq: 2,
+      time: meta.createdAt + 2,
+      data: { target: 'next-turn', start: 0, inserted: [pending] },
+    },
+  ])
+  return meta.id
+}
+
 /** Materialize one detached Session fixture through the shipped JSONL provider. */
 async function persistSeedSession(
   scaffold: WebScaffold,

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

@@ -18,10 +18,9 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/steering', import.meta.url))
 const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
-// Two goldens pin the transient Host projection and its durable handoff: the
-// mid-turn state renders accepted steering from the Session control queue while the
-// question blocks admission, then the settled state renders the same message
-// from user/message beside the reply that obeys it.
+// Two goldens pin both durable surfaces: the mid-turn state renders accepted
+// steering from inbox.next-step while the question blocks admission, then the
+// settled state renders its user/message beside the reply that obeys it.
 const MID_EXPECTED = join(SNAPSHOT_DIR, 'mid-steer.expected.md')
 const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md')
 const MODE = webSnapshotMode()

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

@@ -2,5 +2,5 @@
 # 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 docs/event-producer-consumer.md
-event-producer-consumer.md: 4bc7cfa29b7c32b5e71705096fa8b02a0c7b0adc
-event-producer-consumer.zh.md: 6a22d7b6cb152195e3fd3f2eab35b4142a7beb22
+event-producer-consumer.md: 1153531e1a78d318a254732c1219e0fba883c197
+event-producer-consumer.zh.md: e527c7154db214477f5ed0c5086fd099041d48ea

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

@@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
 | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
 | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
-| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:502`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:482`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:509`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:488`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:495`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:489`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:469`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:496`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:475`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:482`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
 | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
 | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
 | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |

+ 5 - 5
docs/event-producer-consumer.zh.md

@@ -23,11 +23,11 @@
 | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
 | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
 | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
-| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:502`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:482`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:509`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:488`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:495`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:489`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:469`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:496`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:475`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:482`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
 | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
 | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
 | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |

+ 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;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/api/session-controller/README.md
-README.md: 6cae1d4a3a4be04aefd473f0af4cdf2bc86b3cbd
-README.zh.md: 48004caa160764a421ccc5d1d7c9122a2a722e86
+README.md: 396b679edc4d496c77c249dbf3627e27dab0fcc3
+README.zh.md: e248aa29153fdea9b58199987309428d1a8a571a

+ 1 - 1
packages/api/session-controller/README.md

@@ -29,7 +29,7 @@ Each endpoint states its activation policy. List, search, attachment, history pa
 
 `session.updateQueue` resolves the ordinary Session's Agent before locating the addressed `MessageId`, so an edit or removal can mutate a pending row reconstructed from the durable Inbox projection after a Host restart. A missing durable Session, including a Host without persistence, maps to `queue-item-not-found`; other resume failures retain their typed error, and subagent-owned identities remain fenced. Successful mutations append the standard `agent/inbox/spliced` event through the Agent's Inbox.
 
-The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. For each inbox change, the Host publishes the projection frame first and derives the queue replacement from that same validated post-fold value, so listener registration order cannot produce a stale queue frame.
+The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces jobs and projection state instead of treating transient values as durable events. The generic projection carrier delivers the domain-owned `inbox` value for live Agents and cold persisted Sessions; it has no dedicated queue frame or live-Agent queue mirror.
 
 -----
 

+ 1 - 1
packages/api/session-controller/README.zh.md

@@ -29,7 +29,7 @@ kind: "package-reference"
 
 `session.updateQueue` 会先解析普通 Session 的 Agent,再定位被寻址的 `MessageId`,因此 Host 重启后,编辑或移除操作仍可修改从持久 Inbox 投影重建的待处理行。持久 Session 不存在时(包括 Host 未组装 persistence 后端)会映射为 `queue-item-not-found`;其他恢复失败保留其类型化错误,由 subagent 拥有的 identity 仍会被拒绝。成功的变更通过 Agent 的 Inbox 追加标准 `agent/inbox/spliced` 事件。
 
-Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。每次 inbox 变更时,Host 会先发布 projection frame,再从同一份已校验的折叠后值派生 queue replacement,因此监听器注册顺序不会产生陈旧的 queue frame
+Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 jobs 和 projection 状态,而不会把瞬态值当作 durable event。通用 projection carrier 会为 live Agent 和冷持久 Session 投递由领域拥有的 `inbox` 值;系统没有专用 queue frame,也没有枚举 live Agent 的 queue mirror
 
 -----
 

+ 0 - 13
packages/api/session-controller/src/client/contract/snapshot.ts

@@ -1,20 +1,8 @@
 /** Session-owned observable state excluding Conversation target data. */
-import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
-import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
 import type { ClientFailure } from './result.ts'
 
-/** One transient inbox occurrence from the authoritative queue snapshot. */
-export interface QueuedMessage {
-  readonly id: MessageId
-  readonly messageId: MessageId
-  readonly placement: 'queued' | 'steering' | 'context'
-  readonly content: readonly ContentBlock[]
-  readonly preview: string
-  readonly text: string | null
-}
-
 /** History-open lifecycle of a Session event window. */
 export type OpenState = 'cold' | 'loading' | 'open' | 'error'
 
@@ -27,7 +15,6 @@ export interface PromptError {
 /** Immutable Session lifecycle and control snapshot. */
 export interface SessionSnapshot {
   readonly sessionId: SessionId
-  readonly queue: readonly QueuedMessage[]
   readonly running: boolean
   readonly subagent: {
     readonly address: SubagentAddress

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

@@ -54,7 +54,6 @@ export type {
 export type {
   OpenState,
   PromptError,
-  QueuedMessage,
   SessionSnapshot,
 } from './contract/snapshot.ts'
 export type { ClientFailure, ClientResult } from './contract/result.ts'

+ 3 - 25
packages/api/session-controller/src/client/sessions/manager.ts

@@ -8,7 +8,6 @@ import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
 import type {
   SessionControlBaseline,
   SessionControlFrame,
-  SessionQueuedItem,
   SessionError,
   SessionSummary,
   SessionJob as JobView,
@@ -93,8 +92,6 @@ export class SessionManager {
   private readonly sessions = new Map<SessionId, Session>()
   /** In-flight Session disposals remain here after instances leave `sessions`, so manager disposal can await quiescence. */
   private readonly sessionDisposals = new Set<Promise<void>>()
-  /** Latest transient queues, retained independently of Session object materialization. */
-  private readonly queues = new Map<SessionId, readonly SessionQueuedItem[]>()
   /**
    * Sessions that finished running while not selected — the sidebar's green
    * "done" reminder (manager-owned, survives connection generations; cleared
@@ -287,11 +284,6 @@ export class SessionManager {
     if (session === undefined) {
       session = this.createSession(sessionId)
       this.sessions.set(sessionId, session)
-      // Install the latest control baseline before the running-bit sync: a
-      // not-running summary must sweep replayed queue
-      // rows the same way a live status flip would (their retirement events dropped
-      // while the session was uninstantiated).
-      session.replaceControl(this.queues.get(sessionId) ?? [])
       // Sync the running and blank bits from the list snapshot into the new
       // instance (consistency when the list precedes open).
       const summary = this.summaries.find(s => s.sessionId === sessionId)
@@ -679,22 +671,12 @@ export class SessionManager {
       this.notifier.markDirty()
       return
     }
-    if (frame.type === 'jobs') {
-      if (frame.jobs.length === 0) this.jobsBySession.delete(frame.sessionId)
-      else this.jobsBySession.set(frame.sessionId, frame.jobs)
-      this.notifier.markDirty()
-      return
-    }
-    this.queues.set(frame.sessionId, frame.items)
-    this.sessions.get(frame.sessionId)?.handleControlFrame(frame)
+    if (frame.jobs.length === 0) this.jobsBySession.delete(frame.sessionId)
+    else this.jobsBySession.set(frame.sessionId, frame.jobs)
+    this.notifier.markDirty()
   }
 
   private replaceControlBaseline(baseline: SessionControlBaseline): void {
-    this.queues.clear()
-    for (const [sessionId, items] of Object.entries(baseline.queues)) {
-      this.queues.set(sessionId as SessionId, items)
-    }
-
     this.jobsBySession.clear()
     for (const [sessionId, jobs] of Object.entries(baseline.jobs)) {
       if (jobs.length > 0) this.jobsBySession.set(sessionId as SessionId, jobs)
@@ -705,9 +687,6 @@ export class SessionManager {
       store.truncate(block.asOfSeq)
       store.seed(block)
     }
-    for (const [sessionId, session] of this.sessions) {
-      session.replaceControl(this.queues.get(sessionId) ?? [])
-    }
     this.notifier.markDirty()
   }
 
@@ -747,7 +726,6 @@ export class SessionManager {
     this.updateCatalogActivity(sessionId, false)
     if (durableSubagent) this.sessions.get(sessionId)?.handleRunning(false)
     else this.sessions.get(sessionId)?.handleRemoved()
-    this.queues.delete(sessionId)
     this.jobsBySession.delete(sessionId)
     if (!durableSubagent) this.projectionStores.delete(sessionId)
     const inflightCatalog = this.catalogInflight.get(sessionId)

+ 0 - 67
packages/api/session-controller/src/client/sessions/queue-mirror.ts

@@ -1,67 +0,0 @@
-import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
-import type { SessionQueuedItem } from '../../types.ts'
-import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
-import type { QueuedMessage } from '../contract/snapshot.ts'
-
-const QUEUE_PREVIEW_CHARS = 200
-
-function previewOf(content: readonly ContentBlock[]): string {
-  const flat = content
-    .map(block => (block.type === 'text' ? block.text : `[${block.type}]`))
-    .join(' ').replace(/\s+/g, ' ').trim()
-  const chars = Array.from(flat)
-  return chars.length > QUEUE_PREVIEW_CHARS ? `${chars.slice(0, QUEUE_PREVIEW_CHARS).join('')}…` : flat
-}
-
-function textOf(content: readonly ContentBlock[]): string | null {
-  if (!content.every(block => block.type === 'text')) return null
-  return content.map(block => block.text).join('')
-}
-
-type QueueItems = readonly SessionQueuedItem[]
-
-/** Authoritative transient queue projection and durable steering handoff. */
-export class SessionQueueMirror {
-  private current: readonly QueuedMessage[] = []
-
-  /**
-   * Return the current immutable queue projection.
-   * @returns current queue rows.
-   */
-  snapshot(): readonly QueuedMessage[] {
-    return this.current
-  }
-
-  /**
-   * Replace from one authoritative stream queue frame.
-   * @param items - complete host queue snapshot.
-   */
-  replace(items: QueueItems): void {
-    this.current = items.map((item) => {
-      const content = item.message.content as unknown as readonly ContentBlock[]
-      return {
-        id: item.id,
-        messageId: item.message.id,
-        placement: item.placement,
-        content,
-        preview: previewOf(content),
-        text: textOf(content),
-      }
-    })
-  }
-
-  /**
-   * Retire a transient steering row once its durable message enters the log.
-   * @param event - newly contiguous durable Session event.
-   * @returns whether the projection changed.
-   */
-  acceptDurable(event: SessionEvent): boolean {
-    if (event.type !== 'user/message') return false
-    const messageId = event.data.id
-    const index = this.current.findIndex(item =>
-      item.placement === 'steering' && item.messageId === messageId)
-    if (index < 0) return false
-    this.current = this.current.filter((_item, candidate) => candidate !== index)
-    return true
-  }
-}

+ 2 - 27
packages/api/session-controller/src/client/sessions/session.ts

@@ -15,8 +15,6 @@ import type {
   PromptContentPart,
   QueueAction,
   SessionAddress,
-  SessionControlFrame,
-  SessionQueuedItem,
   SessionRequestId,
   SessionError,
 } from '../../types.ts'
@@ -36,7 +34,6 @@ import type { SessionRemotes } from './remotes.ts'
 import { ProjectionValueStore } from './projection-store.ts'
 import type { ProjectionsBaseline } from './projection-store.ts'
 import { resolvedClientTimeZone } from '../time-zone.ts'
-import { SessionQueueMirror } from './queue-mirror.ts'
 
 /** Messages requested per history page. */
 export const PAGE_MESSAGES = 50
@@ -81,8 +78,6 @@ export class Session implements SessionFace {
    *  passes drop all writes once the generation moves on. */
   private openGeneration = 0
   private loadingOlder = false
-  /** Authoritative stream-only inbox snapshot; pending work never hits history. */
-  private readonly queueMirror = new SessionQueueMirror()
   private running = false
   private address: SubagentAddress | undefined
   private parentAvailable: boolean | undefined
@@ -393,7 +388,7 @@ export class Session implements SessionFace {
   }
 
   /** Rebuild an opened history source after address replacement.
-   *  Invalidates any in-flight open first; queue state belongs to the independently
+   *  Invalidates any in-flight open first; projection state belongs to the independently
    *  reconnecting control stream and remains untouched. */
   async resync(): Promise<void> {
     if (this.openState === 'cold') return // never opened: no window to rebuild (doOpen flips to 'loading' synchronously, so cold implies no in-flight open)
@@ -431,24 +426,6 @@ export class Session implements SessionFace {
 
   // ---- Manager-only entry points (@internal; never called by the UI) ----
 
-  /**
-   * Replace every transient control value for this Session from one stream baseline.
-   * @param queue - complete pending queue for this Session.
-   */
-  replaceControl(queue: readonly SessionQueuedItem[]): void {
-    this.queueMirror.replace(queue)
-    this.notifier.markDirty()
-  }
-
-  /**
-   * Apply one Session-addressed live control update.
-   * @param frame - queue replacement addressed to this Session.
-   */
-  handleControlFrame(frame: Extract<SessionControlFrame, { type: 'queue' }>): void {
-    this.queueMirror.replace(frame.items)
-    this.notifier.markDirty()
-  }
-
   /**
    * Running-bit relay from the host stream (list entry and snapshot stay consistent).
    * @param running - the new running state.
@@ -599,9 +576,8 @@ export class Session implements SessionFace {
     const event = entry.event
     const awaitingFirstTurn = this.firstPromptPendingTurn
     if (event.type === 'turn/start') this.firstPromptPendingTurn = false
-    const queueChanged = this.queueMirror.acceptDurable(event)
     this.eventSource.append(entry)
-    return queueChanged || awaitingFirstTurn !== this.firstPromptPendingTurn
+    return awaitingFirstTurn !== this.firstPromptPendingTurn
   }
 
   /** Publish a terminal background failure only while this stream still owns the Session. */
@@ -619,7 +595,6 @@ export class Session implements SessionFace {
   private buildSnapshot(): SessionSnapshot {
     return {
       sessionId: this.sessionId,
-      queue: this.queueMirror.snapshot(),
       running: this.running,
       subagent: this.address === undefined
         ? null

+ 2 - 36
packages/api/session-controller/src/control.ts

@@ -1,7 +1,7 @@
-/** Live Session queue, jobs, and projection state with reconnect baselines. */
+/** Live Session jobs and projection state with reconnect baselines. */
 
 import type { Context } from '@deepseek-ai/cordis'
-import type { Agent, InboxState } from '@deepseek-ai/dsh-agent'
+import type { Agent } from '@deepseek-ai/dsh-agent'
 import type { JobSnapshot } from '@deepseek-ai/dsh-jobs'
 import type {
   JsonValue, Session, SessionId,
@@ -12,7 +12,6 @@ import type {
   SessionJob,
   SessionProjectionBaseline,
   SessionProjectionValues,
-  SessionQueuedItem,
 } from './types.ts'
 
 /** Owns the Host-wide Session control stream. */
@@ -30,14 +29,6 @@ export class SessionControlController {
           value: value as JsonValue,
           seq,
         })
-        if (key !== 'inbox') return
-        const agent = this.ctx.agents.get(session.id)
-        if (agent?.session !== session) return
-        this.broadcast({
-          type: 'queue',
-          sessionId: session.id,
-          items: queueItemsFromInbox(value as InboxState),
-        })
       })
     })
     ctx.inject(['jobs'], (jobsCtx) => {
@@ -73,15 +64,12 @@ export class SessionControlController {
 
   private baseline(): SessionControlBaseline {
     const sessions = this.ctx.sessions.list()
-    const queues = Object.create(null) as Record<SessionId, readonly SessionQueuedItem[]>
     const jobs = Object.create(null) as Record<SessionId, readonly SessionJob[]>
     for (const session of sessions) {
       const agent = this.ctx.agents.get(session.id)
-      queues[session.id] = agent?.session === session ? queueItems(agent) : []
       jobs[session.id] = this.jobsFor(agent)
     }
     return {
-      queues,
       jobs,
       projections: this.projectionBaseline(sessions),
     }
@@ -170,28 +158,6 @@ class ControlQueue {
   }
 }
 
-function queueItems(agent: Agent): SessionQueuedItem[] {
-  return queueItemsFromInbox({
-    'next-turn': agent.inbox.nextTurn,
-    'next-step': agent.inbox.nextStep,
-  })
-}
-
-function queueItemsFromInbox(inbox: InboxState): SessionQueuedItem[] {
-  return [
-    ...inbox['next-turn'].map(message => ({
-      id: message.id,
-      placement: 'queued' as const,
-      message: { id: message.id, content: message.content as unknown as JsonValue[] },
-    })),
-    ...inbox['next-step'].map(message => ({
-      id: message.id,
-      placement: message.source.kind === 'user' ? 'steering' as const : 'context' as const,
-      message: { id: message.id, content: message.content as unknown as JsonValue[] },
-    })),
-  ]
-}
-
 function jobView(job: JobSnapshot): SessionJob {
   return {
     id: job.id,

+ 0 - 13
packages/api/session-controller/src/types.ts

@@ -428,17 +428,6 @@ export type SessionFollowFrame =
   }
   | SessionEventEntry
 
-/** One pending inbox occurrence in the authoritative queue snapshot. */
-export interface SessionQueuedItem {
-  readonly id: MessageId
-  readonly placement: 'queued' | 'steering' | 'context'
-  /** JSON-safe message fields consumed by pending-queue presentation. */
-  readonly message: {
-    readonly id: MessageId
-    readonly content: readonly JsonValue[]
-  }
-}
-
 /** Browser-safe background-job row. */
 export interface SessionJob {
   readonly id: JobId
@@ -452,7 +441,6 @@ export interface SessionJob {
 
 /** Complete live control baseline emitted once per control stream generation. */
 export interface SessionControlBaseline {
-  readonly queues: Readonly<Record<SessionId, readonly SessionQueuedItem[]>>
   readonly jobs: Readonly<Record<SessionId, readonly SessionJob[]>>
   readonly projections: Readonly<Record<SessionId, SessionProjectionBaseline>>
 }
@@ -468,7 +456,6 @@ export interface SessionProjectionUpdate {
 /** Host-wide live state stream. Each generation starts with exactly one baseline. */
 export type SessionControlFrame =
   | { readonly type: 'baseline'; readonly value: SessionControlBaseline }
-  | { readonly type: 'queue'; readonly sessionId: SessionId; readonly items: readonly SessionQueuedItem[] }
   | { readonly type: 'jobs'; readonly sessionId: SessionId; readonly jobs: readonly SessionJob[] }
   | ({ readonly type: 'projection' } & SessionProjectionUpdate)
 

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

@@ -153,7 +153,7 @@ describe('Session Controller Client apply', () => {
 
     expect(accept).toHaveBeenCalledWith({
       type: 'baseline',
-      value: { queues: {}, jobs: {}, projections: {} },
+      value: { jobs: {}, projections: {} },
     })
 
     bench.api.failStreams(new RemoteStreamCarrierError('generation lost'))

+ 0 - 168
packages/api/session-controller/tests/control-queue.host.spec.ts

@@ -1,168 +0,0 @@
-import { Context } from '@deepseek-ai/cordis'
-import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent'
-import type { Agent } from '@deepseek-ai/dsh-agent'
-import { createUserMessage } from '@deepseek-ai/dsh-llm'
-import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
-import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
-import { describe, expect, it } from 'vitest'
-import { SessionControlController } from '../src/control.ts'
-import type { SessionControlFrame } from '../src/types.ts'
-
-async function harness(): Promise<{
-  ctx: Context
-  control: SessionControlController
-  agent: Agent
-  inbox: Inbox
-}> {
-  const ctx = new Context()
-  await ctx.plugin(SessionStore)
-  await ctx.plugin(SessionProjectionRegistry)
-  await ctx.plugin(AgentRegistry)
-  const session = ctx.sessions.create(SessionId('queue-session'))
-  const agent = { id: session.id, session, inbox: undefined as never, status: 'running', ctx } as unknown as Agent
-  Object.assign(agent, { inbox: new Inbox(ctx, agent.session, agentEvents(ctx, agent)) })
-  ctx.agents.register(agent)
-  return { ctx, control: new SessionControlController(ctx), agent, inbox: agent.inbox }
-}
-
-function message(text: string, source: 'user' | 'plugin' = 'user') {
-  return createUserMessage({
-    content: [{ type: 'text', text }],
-    source: source === 'user' ? { kind: 'user' } : { kind: 'plugin', plugin: 'fixture' },
-  })
-}
-
-describe('Session control queue projection', () => {
-  /** Consume frames until the next queue replacement (inbox projection frames interleave). */
-  async function nextQueueFrame(
-    iterator: AsyncIterator<SessionControlFrame>,
-  ): Promise<Extract<SessionControlFrame, { type: 'queue' }>> {
-    for (;;) {
-      const next = await iterator.next()
-      if (next.done) throw new Error('stream ended before a queue frame')
-      if (next.value.type === 'queue') return next.value
-    }
-  }
-
-  it('projects both pending lists in baselines and live replacement frames', async () => {
-    const { control, inbox } = await harness()
-    const queued = message('queued')
-    const steering = message('steering')
-    const context = message('context', 'plugin')
-    inbox.append('next-turn', queued)
-    inbox.append('next-step', steering)
-    inbox.append('next-step', context)
-
-    const abort = new AbortController()
-    const iterator = control.control(abort.signal)[Symbol.asyncIterator]()
-    const opened = await iterator.next()
-    expect(opened.value).toMatchObject({
-      type: 'baseline',
-      value: {
-        queues: {
-          'queue-session': [
-            { id: queued.id, placement: 'queued' },
-            { id: steering.id, placement: 'steering' },
-            { id: context.id, placement: 'context' },
-          ],
-        },
-      },
-    })
-
-    const replacement = message('replacement')
-    inbox.append('next-turn', replacement)
-    const replaced = await nextQueueFrame(iterator)
-    expect(replaced.items.map(item => item.id)).toContain(replacement.id)
-    inbox.remove(steering.id)
-    const removed = await nextQueueFrame(iterator)
-    expect(removed.items.map(item => item.id)).not.toContain(steering.id)
-
-    abort.abort()
-    await iterator.next()
-  })
-
-  it('derives queue replacements from the completed projection regardless of registration order', async () => {
-    const ctx = new Context()
-    await ctx.plugin(SessionStore)
-    await ctx.plugin(AgentRegistry)
-    const control = new SessionControlController(ctx)
-    await ctx.plugin(SessionProjectionRegistry)
-    const session = ctx.sessions.create(SessionId('late-projection-queue'))
-    const agent = { id: session.id, session, inbox: undefined as never, status: 'running', ctx } as unknown as Agent
-    Object.assign(agent, { inbox: new Inbox(ctx, agent.session, agentEvents(ctx, agent)) })
-    ctx.agents.register(agent)
-    const abort = new AbortController()
-    const iterator = control.control(abort.signal)[Symbol.asyncIterator]()
-    await iterator.next()
-    const pending = message('late projection')
-
-    agent.inbox.append('next-turn', pending)
-
-    await expect(iterator.next()).resolves.toMatchObject({
-      value: {
-        type: 'projection',
-        key: 'inbox',
-        value: { 'next-turn': [{ id: pending.id }], 'next-step': [] },
-      },
-    })
-    await expect(nextQueueFrame(iterator)).resolves.toMatchObject({
-      items: [{ id: pending.id, placement: 'queued' }],
-    })
-
-    abort.abort()
-    await iterator.next()
-  })
-
-  it('ignores inbox events without the exact live Agent session', async () => {
-    const { ctx, control, agent, inbox } = await harness()
-    const abort = new AbortController()
-    const iterator = control.control(abort.signal)[Symbol.asyncIterator]()
-    await iterator.next()
-
-    const unrelated = ctx.sessions.create(SessionId('unrelated-queue'))
-    unrelated.append('agent/inbox/spliced', {
-      target: 'next-turn',
-      start: 0,
-      inserted: [message('unrelated')],
-    })
-    const replacement = ctx.sessions.create(SessionId('replacement-session'))
-    Object.defineProperty(agent, 'session', { configurable: true, value: replacement })
-    inbox.append('next-turn', message('wrong-session'))
-
-    abort.abort()
-    await iterator.next()
-  })
-
-  it('drops broadcasts after cancellation has ended its queue', async () => {
-    const { control, inbox } = await harness()
-    const abort = new AbortController()
-    const iterator = control.control(abort.signal)[Symbol.asyncIterator]()
-    await iterator.next()
-    const waiting = iterator.next()
-    await Promise.resolve()
-
-    abort.abort()
-    inbox.append('next-turn', message('late'))
-
-    await expect(waiting).resolves.toMatchObject({ done: true })
-  })
-
-  it('ends active streams on context disposal after flushing buffered frames', async () => {
-    const { ctx, control, inbox } = await harness()
-    const iterator = control.control(new AbortController().signal)[Symbol.asyncIterator]()
-    await iterator.next()
-    const first = message('first')
-    const second = message('second')
-    inbox.append('next-turn', first)
-    inbox.append('next-turn', second)
-
-    const queues: Extract<SessionControlFrame, { type: 'queue' }>[] = []
-    await ctx.fiber.dispose()
-    for (;;) {
-      const next = await iterator.next()
-      if (next.done) break
-      if (next.value.type === 'queue') queues.push(next.value)
-    }
-    expect(queues.map(queue => queue.items.map(item => item.id))).toEqual([[first.id], [first.id, second.id]])
-  })
-})

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

@@ -176,7 +176,6 @@ export class FakeApiClient implements IApiClient {
   /** Optional Host opening cursor override for stale-page and reconnect tests. */
   followCursor: number | undefined
   controlBaseline: SessionControlBaseline = {
-    queues: {},
     jobs: {},
     projections: {},
   }

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

@@ -185,7 +185,7 @@ describe('list lifecycle', () => {
     frame({
       type: 'baseline',
       value: {
-        queues: {}, jobs: {},
+        jobs: {},
         projections: { [S1]: { asOfSeq: 2, values: {} } },
       },
     })
@@ -198,7 +198,7 @@ describe('list lifecycle', () => {
     frame({
       type: 'baseline',
       value: {
-        queues: {}, jobs: {},
+        jobs: {},
         projections: { [S1]: { asOfSeq: 2, values: { title: 'Durable' } } },
       },
     })
@@ -967,7 +967,7 @@ describe('background-job mirror', () => {
     manager.handleControlFrame(tasksFrame(S1, [view()]))
     manager.handleControlFrame({
       type: 'baseline',
-      value: { queues: {}, jobs: {}, projections: {} },
+      value: { jobs: {}, projections: {} },
     })
     expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false)
   })

+ 1 - 1
packages/api/session-controller/tests/projection-store.client.spec.ts

@@ -171,7 +171,7 @@ describe('manager frame routing', () => {
     manager.handleControlFrame({
       type: 'baseline',
       value: {
-        queues: {}, jobs: {},
+        jobs: {},
         projections: { [sid('s1')]: { asOfSeq: 2, values: {} } },
       },
     })

+ 66 - 253
packages/api/session-controller/tests/queue-store.client.spec.ts

@@ -1,287 +1,100 @@
-/**
- * Queue snapshot semantics: authoritative replacement after every host-side
- * change, reconnect re-baselining, pre-instantiation buffering, editable-text
- * projection, and snapshot reference stability.
- */
-import { describe, expect, it, vi } from 'vitest'
+/** Inbox projection delivery and queue-operation transport. */
+
+import { describe, expect, it } from 'vitest'
 import { createUserMessage } from '@deepseek-ai/dsh-llm'
-import type { ContentBlock, UserMessage } from '@deepseek-ai/dsh-llm/types'
-import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
-import type { MessageId, RpcId, SessionId } from '@deepseek-ai/dsh-api-remotes/client'
+import type { InboxState } from '@deepseek-ai/dsh-agent/types'
+import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
 import type { SessionControlFrame } from '@deepseek-ai/dsh-api-session-controller/types'
-import { Session } from '../src/client/sessions/session.ts'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import { SessionManager } from '../src/client/sessions/manager.ts'
 import { FakeApiClient, fakeRemote } from './fake-api.client.ts'
 
 const SID = 'fk-q1' as SessionId
 const text = (value: string): ContentBlock[] => [{ type: 'text', text: value }]
-const rid = (id: string): RpcId => id as RpcId
-const iid = (id: string): MessageId => id as MessageId
 
-interface QueueFixture {
-  id: string
-  body: string
-  content?: ContentBlock[]
-  placement?: 'queued' | 'steering'
-  message?: UserMessage
+let nextSeq = 1
+
+function message(label: string, body: string) {
+  return createUserMessage({
+    content: text(body),
+    source: { kind: 'user', rpcId: `rpc-${label}` } as never,
+  })
 }
 
-/** Build one authoritative queue snapshot. */
-function queueFrame(items: QueueFixture[]): Extract<SessionControlFrame, { type: 'queue' }> {
+function inboxFrame(value: InboxState): Extract<SessionControlFrame, { type: 'projection' }> {
   return {
-    type: 'queue',
+    type: 'projection',
     sessionId: SID,
-    items: items.map(item => ({
-      id: iid(item.id),
-      placement: item.placement ?? 'queued',
-      message: (item.message ?? createUserMessage({
-        content: item.content ?? text(item.body),
-        source: { kind: 'user', rpcId: rid(`rpc-${item.id}`) } as never,
-      })) as never,
-    })),
+    key: 'inbox',
+    seq: nextSeq++,
+    value: value as never,
   }
 }
 
-function makeSession(): Session {
-  return makeBench().session
-}
+describe('Inbox projection intake', () => {
+  it('stores the complete Agent-owned value without adding queue state to the Session snapshot', () => {
+    const manager = new SessionManager(fakeRemote(new FakeApiClient()))
+    const queued = message('queued', 'later')
+    const steering = message('steering', 'now')
+    const value = { 'next-turn': [queued], 'next-step': [steering] }
 
-function makeBench(): { api: FakeApiClient; session: Session } {
-  const api = new FakeApiClient()
-  return { api, session: new Session(SID, fakeRemote(api)) }
-}
-
-function makeManager(): SessionManager {
-  const api = new FakeApiClient()
-  return new SessionManager(fakeRemote(api))
-}
+    manager.handleControlFrame(inboxFrame(value))
+    const session = manager.get(SID)
 
-describe('Session queue snapshot intake', () => {
-  it('projects stable ids, flat previews, and complete text', () => {
-    const session = makeSession()
-    session.handleControlFrame(queueFrame([
-      { id: 'q-1', body: '第一条  排队\n消息' },
-    ]))
-    const queue = session.getSnapshot().queue
-    expect(typeof queue[0]?.messageId).toBe('string')
-    expect(queue).toMatchObject([
-      {
-        id: 'q-1', placement: 'queued',
-        content: [{ type: 'text', text: '第一条  排队\n消息' }],
-        preview: '第一条 排队 消息', text: '第一条  排队\n消息',
-      },
-    ])
-  })
-
-  it('marks mixed-content messages non-editable while retaining their preview', () => {
-    const session = makeSession()
-    session.handleControlFrame(queueFrame([{
-      id: 'q-image',
-      body: '',
-      content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never],
-    }]))
-    const queue = session.getSnapshot().queue
-    expect(typeof queue[0]?.messageId).toBe('string')
-    expect(queue).toMatchObject([
-      {
-        id: 'q-image', placement: 'queued',
-        content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' }],
-        preview: 'hi [image]', text: null,
-      },
-    ])
+    expect(session.projections.faceOf('inbox').getSnapshot()).toEqual(value)
+    expect(session.getSnapshot()).not.toHaveProperty('queue')
   })
 
-  it('caps previews at 200 code points and preserves the full editable text', () => {
-    const session = makeSession()
-    const body = '长'.repeat(201)
-    session.handleControlFrame(queueFrame([{ id: 'q-cap', body }]))
-    const row = session.getSnapshot().queue[0]
-    expect(Array.from(row?.preview ?? '')).toHaveLength(201)
-    expect(row?.preview.endsWith('…')).toBe(true)
-    expect(row?.text).toBe(body)
-  })
+  it('drops a projection that claims events beyond a reconnect baseline', () => {
+    const manager = new SessionManager(fakeRemote(new FakeApiClient()))
+    manager.handleControlFrame(inboxFrame({
+      'next-turn': [message('stale', 'stale')],
+      'next-step': [],
+    }))
 
-  it('replaces content, order, and membership from each authoritative frame', () => {
-    const session = makeSession()
-    session.handleControlFrame(queueFrame([
-      { id: 'q-1', body: 'one' },
-      { id: 'q-2', body: 'two' },
-    ]))
-    session.handleControlFrame(queueFrame([
-      { id: 'q-2', body: 'two edited' },
-    ]))
-    const queue = session.getSnapshot().queue
-    expect(typeof queue[0]?.messageId).toBe('string')
-    expect(queue).toMatchObject([
-      {
-        id: 'q-2', placement: 'queued',
-        content: [{ type: 'text', text: 'two edited' }],
-        preview: 'two edited', text: 'two edited',
+    manager.handleControlFrame({
+      type: 'baseline',
+      value: {
+        jobs: {},
+        projections: { [SID]: { asOfSeq: 0, values: {} } },
       },
-    ])
-    session.handleControlFrame(queueFrame([]))
-    expect(session.getSnapshot().queue).toEqual([])
-  })
-
-  it('keeps the queue array reference stable across unrelated snapshot swaps', () => {
-    const session = makeSession()
-    session.handleControlFrame(queueFrame([{ id: 'q-stable', body: '稳定' }]))
-    const before = session.getSnapshot().queue
-    session.handleAgentError('unrelated')
-    expect(session.getSnapshot().queue).toBe(before)
-  })
-
-  it('retains steering placement and complete content in the same authoritative snapshot', () => {
-    const session = makeSession()
-    session.handleControlFrame(queueFrame([
-      { id: 'q-next', body: 'later' },
-      { id: 's-now', body: 'interrupt now', placement: 'steering' },
-    ]))
-
-    expect(session.getSnapshot().queue.map(item => ({
-      id: item.id, placement: item.placement, content: item.content,
-    }))).toEqual([
-      { id: 'q-next', placement: 'queued', content: text('later') },
-      { id: 's-now', placement: 'steering', content: text('interrupt now') },
-    ])
-  })
-
-  it('hands off exactly one current occurrence when live steering becomes durable', async () => {
-    const { api, session } = makeBench()
-    await session.open()
-    const message = createUserMessage({
-      content: text('same message'),
-      source: { kind: 'user' },
     })
-    session.handleControlFrame(queueFrame([
-      { id: 's-first', body: '', placement: 'steering', message },
-      { id: 's-second', body: '', placement: 'steering', message },
-    ]))
-    const durable = {
-      seq: 0,
-      time: 1_700_000_000_000,
-      type: 'user/message',
-      surfaceOp: 'append',
-      data: message,
-    } as SessionEvent
 
-    await api.pushFollow(SID, { type: 'event', event: durable as never })
-    await vi.waitFor(() => {
-      expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-second'])
-    })
-
-    session.handleControlFrame(queueFrame([
-      { id: 's-later', body: '', placement: 'steering', message },
-    ]))
-    await api.pushFollow(SID, { type: 'event', event: durable as never })
-    await vi.waitFor(() => {
-      expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-later'])
-    })
+    expect(manager.get(SID).projections.faceOf('inbox').getSnapshot()).toBeUndefined()
   })
 
-  it('hands off live steering when the agent claims it as a user message', async () => {
-    const { api, session } = makeBench()
-    await session.open()
-    const message = createUserMessage({
-      content: text('claimed steering'),
-      source: { kind: 'user' },
-    })
-    session.handleControlFrame(queueFrame([
-      { id: 's-claimed', body: '', placement: 'steering', message },
-    ]))
-
-    await api.pushFollow(SID, {
-      type: 'event',
-      event: {
-        seq: 0,
-        time: 1_700_000_000_000,
-        type: 'user/message',
-        surfaceOp: 'append',
-        data: message,
-      } as never,
-    })
-
-    await vi.waitFor(() => {
-      expect(session.getSnapshot().queue).toEqual([])
-    })
+  it('retains only the highest-seq value received before Session materialization', () => {
+    const manager = new SessionManager(fakeRemote(new FakeApiClient()))
+    manager.handleControlFrame(inboxFrame({
+      'next-turn': [message('old', 'old')],
+      'next-step': [],
+    }))
+    const latest = {
+      'next-turn': [message('latest', 'latest')],
+      'next-step': [],
+    }
+    manager.handleControlFrame(inboxFrame(latest))
+
+    expect(manager.get(SID).projections.faceOf('inbox').getSnapshot()).toEqual(latest)
   })
 })
 
 describe('queue operation transport', () => {
-  it('addresses the session.updateQueue RPC without optimistic local mutation', async () => {
+  it('does not mutate the Inbox projection before the Host publishes its committed value', async () => {
     const api = new FakeApiClient()
-    const session = new Session(SID, fakeRemote(api))
-    session.handleControlFrame(queueFrame([{ id: 'q-op', body: 'pending' }]))
-    const before = session.getSnapshot().queue
+    const manager = new SessionManager(fakeRemote(api))
+    const pending = message('pending', 'before')
+    const initial = { 'next-turn': [pending], 'next-step': [] }
+    manager.handleControlFrame(inboxFrame(initial))
+    const session = manager.get(SID)
 
-    await expect(session.updateQueue(iid('q-op'), { kind: 'edit', content: text('next') }))
-      .resolves.toEqual({ ok: true, value: { accepted: true } })
-    await expect(session.updateQueue(iid('q-op'), { kind: 'steer' }))
+    await expect(session.updateQueue(pending.id, { kind: 'edit', content: text('after') }))
       .resolves.toEqual({ ok: true, value: { accepted: true } })
-    expect(api.callsOf('session.updateQueue')).toEqual([
-      {
-        sessionId: SID,
-        itemId: 'q-op',
-        action: { kind: 'edit', content: text('next') },
-      },
-      {
-        sessionId: SID,
-        itemId: 'q-op',
-        action: { kind: 'steer' },
-      },
-    ])
-    expect(session.getSnapshot().queue).toBe(before)
-  })
-})
-
-describe('queue reconnect semantics', () => {
-  it('a control baseline clears stale state before a fresh update lands', () => {
-    const session = makeSession()
-    session.handleControlFrame(queueFrame([{ id: 'q-old', body: '旧连接' }]))
-    session.replaceControl([])
-    expect(session.getSnapshot().queue).toEqual([])
-    session.handleControlFrame(queueFrame([{ id: 'q-new', body: '新基线' }]))
-    expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-new'])
-  })
-
-  it('resync does not clear a baseline that raced ahead of the host connection signal', async () => {
-    const session = makeSession()
-    await session.open()
-    session.handleControlFrame(queueFrame([{ id: 'q-fresh', body: '新基线' }]))
-    await session.resync()
-    expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-fresh'])
-  })
-
-  it('running-status changes never guess at queue retirement', () => {
-    const session = makeSession()
-    session.handleControlFrame(queueFrame([{ id: 'q-live', body: '保留' }]))
-    session.handleRunning(true)
-    session.handleRunning(false)
-    expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-live'])
-  })
-})
-
-describe('manager buffering of queue snapshots', () => {
-  it('replays only the latest snapshot for an uninstantiated session', () => {
-    const manager = makeManager()
-    manager.handleControlFrame(queueFrame([{ id: 'q-old', body: '旧' }]))
-    manager.handleControlFrame(queueFrame([{ id: 'q-new', body: '新' }]))
-    expect(manager.get(SID).getSnapshot().queue.map(row => row.id)).toEqual(['q-new'])
-  })
-
-  it('a control baseline replaces the prior queue', () => {
-    const manager = makeManager()
-    manager.handleControlFrame(queueFrame([{ id: 'q-g1', body: '第一代' }]))
-    const nextQueue = queueFrame([{ id: 'q-g2', body: '第二代' }]).items
-    manager.handleControlFrame({
-      type: 'baseline',
-      value: {
-        queues: { [SID]: nextQueue },
-        jobs: {},
-        projections: {},
-      },
-    })
-    const snapshot = manager.get(SID).getSnapshot()
-    expect(snapshot.queue.map(row => row.id)).toEqual(['q-g2'])
+    expect(api.callsOf('session.updateQueue')).toEqual([{
+      sessionId: SID,
+      itemId: pending.id,
+      action: { kind: 'edit', content: text('after') },
+    }])
+    expect(session.projections.faceOf('inbox').getSnapshot()).toBe(initial)
   })
 })

+ 1 - 1
packages/api/session-controller/tests/sessions-service.client.spec.ts

@@ -139,7 +139,7 @@ describe('scope tree', () => {
 
     b.svc.handleControlFrame({
       type: 'baseline',
-      value: { queues: {}, jobs: {}, projections: {} },
+      value: { jobs: {}, projections: {} },
     })
     await Promise.resolve()
     expect(b.svc.resolveAgentScope(sid('s-early'))).toBe(scoped)

+ 3 - 3
packages/api/session-controller/tests/transport.client.spec.ts

@@ -322,10 +322,10 @@ describe('Session Client stream adapters', () => {
   it('maps the Host-wide control baseline and deltas into one snapshot stream', async () => {
     const baseline: SessionControlFrame = {
       type: 'baseline',
-      value: { queues: {}, jobs: {}, projections: {} },
+      value: { jobs: {}, projections: {} },
     }
     const update: SessionControlFrame = {
-      type: 'queue', sessionId: 'session-1' as never, items: [],
+      type: 'jobs', sessionId: 'session-1' as never, jobs: [],
     }
     const remote = new ScriptedSessionRemote([], [], [baseline, update])
     const accept = vi.fn<(frame: SessionControlFrame) => void>()
@@ -357,7 +357,7 @@ describe('Session Client stream adapters', () => {
 
     const baseline: SessionControlFrame = {
       type: 'baseline',
-      value: { queues: {}, jobs: {}, projections: {} },
+      value: { jobs: {}, projections: {} },
     }
     const carrierFailed = vi.fn()
     const failed = vi.fn()

+ 0 - 4
packages/client/connection/src/client/fixture.ts

@@ -199,7 +199,6 @@ type FixtureControlFrame =
   | {
     readonly type: 'baseline'
     readonly value: {
-      readonly queues: Readonly<Record<string, readonly never[]>>
       readonly jobs: Readonly<Record<string, readonly never[]>>
       readonly approvals: readonly never[]
       readonly questions: readonly never[]
@@ -3061,11 +3060,9 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
   }
 
   const controlBaseline = (): Extract<FixtureControlFrame, { type: 'baseline' }> => {
-    const queues: Record<string, readonly never[]> = {}
     const jobs: Record<string, readonly never[]> = {}
     const projections: Record<string, FixtureProjectionsBlock> = {}
     for (const summary of sessions) {
-      queues[summary.sessionId] = []
       jobs[summary.sessionId] = []
       const log = logs.get(summary.sessionId) ?? []
       projections[summary.sessionId] = {
@@ -3076,7 +3073,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
     return {
       type: 'baseline',
       value: {
-        queues,
         jobs,
         approvals: [],
         questions: [],

+ 0 - 1
packages/client/connection/tests/fixture.client.spec.ts

@@ -97,7 +97,6 @@ type FixtureControlFrame =
   | {
     readonly type: 'baseline'
     readonly value: {
-      readonly queues: Readonly<Record<string, readonly unknown[]>>
       readonly jobs: Readonly<Record<string, readonly unknown[]>>
       readonly approvals: readonly unknown[]
       readonly questions: readonly unknown[]

+ 5 - 3
packages/client/ui-chat/src/client/chat/ChatView.tsx

@@ -2,6 +2,7 @@
 // otherwise this view owns it. Each row subscribes to one stable node key.
 
 import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
+import type { InboxState } from '@deepseek-ai/dsh-agent/types'
 import type {
   ConversationTimelineSnapshot, RenderMessageImages,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -173,7 +174,8 @@ function TurnStatus({ startTime, t }: {
  * ordered business Node crosses the keyed renderer seat.
  */
 export function ChatView({
-  useSession, useChat, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, loadImage, openView, chatScroll, forkAt,
+  useSession, useProjection, useChat, useSessions, useStore, renderSlot, sessionId,
+  openFile, loadOlder, loadImage, openView, chatScroll, forkAt,
   fileMentions, t,
 }: ChatViewSlotProps) {
   const order = useChat(s => s.order)
@@ -183,7 +185,7 @@ export function ChatView({
   // Turn enters, leaves, or changes its preview.
   const turnNavigationItems = useChat(s => s.navigation.items())
   const timeline = useChat(s => s.timeline)
-  const inbox = useSession(s => s.queue)
+  const inbox = useProjection('inbox') as unknown as InboxState | undefined
   // Workspace root off the session list row: path summaries display relative to it.
   const cwd = useSessions(s => s.byId[sessionId]?.cwd)
   const running = useSession(s => s.running)
@@ -231,7 +233,7 @@ export function ChatView({
   }, [])
 
   const pendingSteering = useMemo(
-    () => inbox.filter(item => item.placement === 'steering'),
+    () => inbox?.['next-step'].filter(message => message.source.kind === 'user') ?? [],
     [inbox],
   )
   const renderMessageImages = useCallback<RenderMessageImages>(

+ 34 - 39
packages/client/ui-chat/tests/chat-view.client.spec.tsx

@@ -13,6 +13,9 @@ import type {
 import type {
   SessionListState, SessionSnapshot,
 } from '@deepseek-ai/dsh-api-session-controller/client'
+import type { InboxState } from '@deepseek-ai/dsh-agent/types'
+import type { UserMessage } from '@deepseek-ai/dsh-llm/types'
+import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
 import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
@@ -47,10 +50,18 @@ beforeEach(() => {
 const SID = 's1' as SessionId
 type RoutedChatNodeOwner = ChatNodeOwnerProps & { readonly node: ChatNode }
 
+function pendingMessage(id: string, text: string): UserMessage {
+  return {
+    id: id as MessageId,
+    role: 'user',
+    content: [{ type: 'text', text }],
+    source: { kind: 'user' },
+  }
+}
+
 function sessionSnapshot(overrides: Partial<SessionSnapshot> = {}): SessionSnapshot {
   return {
     sessionId: SID,
-    queue: [],
     running: false,
     removed: false,
     openState: 'open',
@@ -180,8 +191,11 @@ function makeHarness(
   chatSlice: ChatSlice = {},
   sessionInit: Partial<SessionSnapshot> = {},
   chatSnapshot?: ChatSnapshot,
+  inboxInit: InboxState = { 'next-turn': [], 'next-step': [] },
 ) {
   const session = makeSessionSource(sessionInit)
+  const inbox = createSnapshotStore<InboxState | undefined>(inboxInit)
+  const useInbox = bindSnapshotSelector(inbox)
   const chatSource = makeChatSource(chatSlice, chatSnapshot)
   const openDetails = vi.fn<(t: SelectionTarget) => void>()
   const openFile = vi.fn<(path: string) => Promise<void>>().mockResolvedValue(undefined)
@@ -304,7 +318,10 @@ function makeHarness(
       createSnapshotStore<SessionPendingInteractionSnapshot>(new Map()),
     ),
     useWorkspaces: emptyWorkspaces(),
-    useProjection: (() => undefined),
+    useProjection: ((
+      key: string,
+      selector: (value: unknown) => unknown = value => value,
+    ) => useInbox(value => selector(key === 'inbox' ? value : undefined))),
     useInput: (() => { throw new Error('unused') }),
     inputActions: {
       setDraft: () => {},
@@ -333,6 +350,7 @@ function makeHarness(
   const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) }
   return {
     setSession: session.set, setChat: chatSource.set, ChatView, props,
+    setInbox: (value: InboxState) => { inbox.set(value) },
     openDetails, openFile, loadOlder, openView,
     chatScroll, forkAt, setSelection, toolOwners,
   }
@@ -640,25 +658,13 @@ describe('ChatView', () => {
       configurable: true,
       value: { writeText },
     })
-    const pending = {
-      id: 'steer-occurrence' as never,
-      messageId: 'steer-message' as never,
-      placement: 'steering' as const,
-      content: [{ type: 'text' as const, text: 'interrupt now' }],
-      preview: 'interrupt now',
-      text: 'interrupt now',
-    }
-    const queued = {
-      id: 'queued-occurrence' as never,
-      messageId: 'queued-message' as never,
-      placement: 'queued' as const,
-      content: [{ type: 'text' as const, text: 'later' }],
-      preview: 'later',
-      text: 'later',
-    }
+    const pending = pendingMessage('steer-message', 'interrupt now')
+    const queued = pendingMessage('queued-message', 'later')
     const h = makeHarness(
       { nodes: [assistant(1, 'working')] },
-      { queue: [queued, pending], running: true },
+      { running: true },
+      undefined,
+      { 'next-turn': [queued], 'next-step': [pending] },
     )
     const view = render(<h.ChatView {...h.props} />)
 
@@ -673,12 +679,12 @@ describe('ChatView', () => {
       & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
 
     act(() => {
-      h.setSession({ queue: [queued] })
+      h.setInbox({ 'next-turn': [queued], 'next-step': [] })
       h.setChat({
         nodes: [
           assistant(1, 'working'),
           {
-            kind: 'steering', messageId: pending.messageId,
+            kind: 'steering', messageId: pending.id,
             seq: 2, time: 2_000,
             content: [{ type: 'text', text: 'interrupt now' }], source: null,
           },
@@ -708,20 +714,13 @@ describe('ChatView', () => {
   })
 
   it('keeps a later pending occurrence visible when it reuses a durable MessageId', () => {
-    const pending = {
-      id: 'steer-occurrence-later' as never,
-      messageId: 'shared-steer-message' as never,
-      placement: 'steering' as const,
-      content: [{ type: 'text' as const, text: 'same steering' }],
-      preview: 'same steering',
-      text: 'same steering',
-    }
+    const pending = pendingMessage('shared-steer-message', 'same steering')
     const h = makeHarness({
       nodes: [{
-        kind: 'user', seq: 2, time: 2_000,
+        kind: 'steering', messageId: pending.id, seq: 2, time: 2_000,
         content: pending.content, source: null,
       }],
-    }, { queue: [pending], running: true })
+    }, { running: true }, undefined, { 'next-turn': [], 'next-step': [pending] })
     const view = render(<h.ChatView {...h.props} />)
 
     expect(view.getAllByText('same steering')).toHaveLength(2)
@@ -1132,14 +1131,10 @@ describe('ChatView', () => {
     expect(status.textContent).toMatch(/^深度求索中\.\.\.2分0\d秒$/)
     expect(status.querySelector('[aria-hidden="true"]')).not.toBeNull()
     act(() => {
-      h.setSession({ queue: [{
-        id: 'steering-occurrence' as never,
-        messageId: 'steering-message' as never,
-        placement: 'steering',
-        content: [{ type: 'text', text: 'also' }],
-        preview: 'also',
-        text: 'also',
-      }] })
+      h.setInbox({
+        'next-turn': [],
+        'next-step': [pendingMessage('steering-message', 'also')],
+      })
     })
     expect(status.textContent).toMatch(/^深度求索中\.\.\.2分0\d秒$/)
   })

+ 0 - 1
packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx

@@ -56,7 +56,6 @@ function renderToolDetailsProbe(owners?: DetailsToolOwnerProps[]): DetailsSlotPr
 function sessionSnapshot(): SessionSnapshot {
   return {
     sessionId: SID,
-    queue: [],
     running: false,
     removed: false,
     openState: 'open',

+ 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;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
-README.md: 188a4461c1ab46e8be4f96c402c4707b1c66c415
-README.zh.md: 63356b04ef07ddd8c037d46d0ad105ed01afda80
+README.md: f33f2f585e594c4393d62c98167891eb4ad41e75
+README.zh.md: 77d22088b895f121879f2ccc47432781623231e0

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

@@ -38,7 +38,7 @@ The package registers the optional-Session `conversation` shell, strict Session
 
 View selection is deterministic: a registered persisted selection wins, otherwise registered `chat` wins, otherwise no View renders. It never chooses the first registered View. Shell phase combines Session lifecycle with the active-target set; no target-specific snapshot is read by the shell.
 
-The resident composer survives no-Session and Session transitions. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label), while an edit exposes the literal sent text. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace.
+The resident composer survives no-Session and Session transitions. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. Queue operations address exact pending messages through the scoped `ctx.conversation` service; QueueDock and the input machine read `inbox['next-turn']` from the generic Session projection store, without a second client queue mirror. Queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label), while an edit exposes the literal sent text. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace.
 
 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. Continuable subagents keep separate Send and Stop actions ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.md)).
 

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

@@ -38,7 +38,7 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map,
 
 View 选择规则固定:有效且已注册的持久化选择优先,其次是已注册的 `chat`,否则不渲染 View;绝不选择第一个已注册 View。Shell phase 只组合 Session lifecycle 与 active-target set,不读取任何 target-specific snapshot。
 
-常驻 composer 在无 Session 与有 Session 之间保持挂载。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),编辑态则展示字面发送文本。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。
+常驻 composer 在无 Session 与有 Session 之间保持挂载。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的待处理消息;QueueDock 和 input machine 从通用 Session projection store 读取 `inbox['next-turn']`,不再维护第二份客户端 queue mirror。Queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),编辑态则展示字面发送文本。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。
 
 普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Queue Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置继续选择 Queue 或 Steer 键盘操作。可继续 subagent 保留独立的 Send 与 Stop 操作([决策](../../../.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.zh.md))。
 

+ 2 - 0
packages/client/ui-conversation/package.json

@@ -59,6 +59,7 @@
   },
   "peerDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-agent": "workspace:^",
     "@deepseek-ai/dsh-api-remotes": "workspace:^",
     "@deepseek-ai/dsh-api-session-controller": "workspace:^",
     "@deepseek-ai/dsh-api-workspace-controller": "workspace:^",
@@ -87,6 +88,7 @@
   },
   "devDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-agent": "workspace:^",
     "@deepseek-ai/dsh-api-remotes": "workspace:^",
     "@deepseek-ai/dsh-api-session-controller": "workspace:^",
     "@deepseek-ai/dsh-api-workspace-controller": "workspace:^",

+ 3 - 6
packages/client/ui-conversation/src/client/contract/input.ts

@@ -7,10 +7,10 @@
  * here is the submit plane (phase, claim, attempt) alone.
  */
 import type { Context } from '@deepseek-ai/cordis'
+import type { InboxState } from '@deepseek-ai/dsh-agent/types'
 import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store'
 import type { Branded } from '@deepseek-ai/dsh-brand'
 import type { LexicalEditor } from 'lexical'
-import type { QueueRow } from './queue.ts'
 import type { InputSubmitMode } from './composer-submission.ts'
 
 /** Pick-time draft span guarded by the input revision. */
@@ -276,9 +276,6 @@ export interface ComposerKeyboard {
   dismissPopup(): void
 }
 
-/** One independently addressable row projected from the transient queue snapshot. */
-export type QueuedMessage = QueueRow
-
 /** Guard union of the scoped consume-token event, checked by the shell. */
 export type ConsumeTokenGuard = ConsumeTokenRequest['guard']
 
@@ -330,8 +327,8 @@ export interface InputState {
   readonly claim?: { readonly token: string; readonly hint?: string; readonly images?: boolean }
   /** Reference occurrence view of the editor's chips, sorted by offset. */
   readonly occurrences: readonly Occurrence[]
-  /** Read-only transient inbox projection from Session control, including pending steering. */
-  readonly queue: readonly QueuedMessage[]
+  /** Messages still waiting for their own turn. */
+  readonly queue: InboxState['next-turn']
 }
 
 /**

+ 0 - 11
packages/client/ui-conversation/src/client/contract/queue.ts

@@ -1,11 +0,0 @@
-/** Queue contracts derived from the Session Controller face. */
-import type { SessionFace, SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client'
-
-/** One address accepted by the Session Controller's queue mutation verb. */
-export type QueueItemId = Parameters<SessionFace['updateQueue']>[0]
-
-/** One mutation accepted by the Session Controller's queue mutation verb. */
-export type QueueAction = Parameters<SessionFace['updateQueue']>[1]
-
-/** One row projected by the authoritative Session queue snapshot. */
-export type QueueRow = SessionSnapshot['queue'][number]

+ 7 - 6
packages/client/ui-conversation/src/client/input/facade.ts

@@ -9,6 +9,7 @@
  * listeners onto it.
  */
 import type { Context } from '@deepseek-ai/cordis'
+import type { InboxState } from '@deepseek-ai/dsh-agent/types'
 import {
   createSnapshotStore, type ObservableSnapshot, type SnapshotStore,
 } from '@deepseek-ai/dsh-client-store'
@@ -23,7 +24,7 @@ import { mergeRegister } from '@lexical/utils'
 import type {
   ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, DraftAttachmentId,
   InputActions, InputEffect, InputNotice, InputState, InputTriggerController, PickOutcome,
-  QueuedMessage, ReferenceInsert, SessionInput, SubmitAttempt, SubmitImageAttachment,
+  ReferenceInsert, SessionInput, SubmitAttempt, SubmitImageAttachment,
   SubmitOutcome, TokenSpan,
 } from '../contract/input.ts'
 import type { InputSubmitMode } from '../contract/composer-submission.ts'
@@ -53,8 +54,8 @@ export interface SessionInputDeps {
   inputTriggers?: (() => InputTriggerController | undefined) | undefined
   /** PopupSelect shell face resolver (dismissal on submit lock / escape). */
   popup?: (() => PopupDismissFace | undefined) | undefined
-  /** Queue read face; overlaid onto InputState.queue (absent = empty). */
-  queue?: ObservableSnapshot<readonly QueuedMessage[]> | undefined
+  /** Agent Inbox projection; its next-turn list is overlaid onto InputState.queue. */
+  inbox?: ObservableSnapshot<InboxState | undefined> | undefined
   /**
    * Steer every still-pending queued message into the running turn, in FIFO
    * order (the empty-draft accelerated-Enter gesture); absent = unsupported.
@@ -97,7 +98,7 @@ function projectionContentChanged(prev: EditorProjection, next: EditorProjection
   })
 }
 
-const EMPTY_QUEUE: readonly QueuedMessage[] = []
+const EMPTY_QUEUE: InboxState['next-turn'] = []
 
 /** No-pipeline lexicon: zero text-ref decorations. */
 const EMPTY_LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map()
@@ -167,7 +168,7 @@ export class SessionInputShell implements SessionInput {
       () => { this.lexiconOff?.() },
     )
     this.state = createSnapshotStore<InputState>(this.compose())
-    deps.queue?.subscribe(() => { this.publish() })
+    deps.inbox?.subscribe(() => { this.publish() })
   }
 
   // ---- editor plumbing ----
@@ -792,7 +793,7 @@ export class SessionInputShell implements SessionInput {
       phase: core.phase,
       ...(core.claim !== undefined ? { claim: core.claim } : {}),
       occurrences: this.projection.occurrences,
-      queue: this.deps.queue?.getSnapshot() ?? EMPTY_QUEUE,
+      queue: this.deps.inbox?.getSnapshot()?.['next-turn'] ?? EMPTY_QUEUE,
     }
   }
 

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

@@ -8,12 +8,13 @@
  * real host entity, so the sink is one unconditional prompt path.
  */
 import type { Context } from '@deepseek-ai/cordis'
+import type { InboxState } from '@deepseek-ai/dsh-agent/types'
 import type {
   ISessions, SessionBinding, SessionFace,
 } from '@deepseek-ai/dsh-api-session-controller/client'
+import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client'
-import { queueReadFaceOf } from './queue-store.ts'
 import type {
   ComposerKeyboard, DraftAttachmentId, InputTriggerController, SessionInputResolver, SessionInput,
   SubmitImageAttachment, SubmitOutcome,
@@ -87,7 +88,7 @@ export class InputHub implements SessionInputResolver {
       actx,
       inputTriggers: () => this.controller(actx),
       popup: () => this.popup(actx),
-      queue: queueReadFaceOf(session),
+      inbox: session.projections.faceOf('inbox') as ObservableSnapshot<InboxState | undefined>,
       defaultSink: (text, imageIds, mode, signal) => this.sink(session, text, imageIds, mode, signal),
       steerQueue: () => { void this.steerQueue(session, shell) },
       commandImages: {
@@ -197,7 +198,7 @@ export class InputHub implements SessionInputResolver {
    * @param shell - the resident shell (notice outlet).
    */
   private async steerQueue(session: SessionFace, shell: SessionInputShell): Promise<void> {
-    const queued = session.getSnapshot().queue.filter(item => item.placement === 'queued')
+    const queued = shell.snapshot.queue
     if (queued.length === 0) return
     for (const item of queued) {
       const result = await session.updateQueue(item.id, { kind: 'steer' })

+ 0 - 25
packages/client/ui-conversation/src/client/input/queue-store.ts

@@ -1,25 +0,0 @@
-/**
- * Queue read face for the InputState.queue projection (frozen contract in
- * ../contract/input.ts): a uSES-compatible observable over one session's
- * transient inbox rows. The Session snapshot already keeps the queue array
- * reference-stable across unrelated snapshot swaps, so this is a pure
- * projection — no second store, no copy.
- */
-import type { SessionFace } from '@deepseek-ai/dsh-api-session-controller/client'
-import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
-import type { QueuedMessage } from '../contract/input.ts'
-
-/**
- * Project a session's transient inbox rows as a bare observable (subscribe/getSnapshot).
- * The wiring layer overlays this onto InputState.queue; the runtime
- * QueuedMessage and the input-contract QueuedMessage are structurally
- * identical.
- * @param session - the resident session face.
- * @returns the queue read face (snapshot reference stable while the queue is unchanged).
- */
-export function queueReadFaceOf(session: SessionFace): ObservableSnapshot<readonly QueuedMessage[]> {
-  return {
-    getSnapshot: () => session.getSnapshot().queue,
-    subscribe: fn => session.subscribe(fn),
-  }
-}

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

@@ -1,18 +1,36 @@
 import type { Context } from '@deepseek-ai/cordis'
-import { useEffect, useId, useMemo, useState } from 'react'
+import type { InboxState } from '@deepseek-ai/dsh-agent/types'
+import type { QueueAction } from '@deepseek-ai/dsh-api-session-controller/types'
+import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
+import { useEffect, useId, useState } from 'react'
 import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import {
   IconCheckOutline16, IconChevronDownOutline14, IconChevronUpOutline14, IconCloseOutline16,
   IconEditOutline16, IconQueueOutline14, IconSendOutline14, IconTrashOutline16, projectUserText, Tooltip,
 } from '@deepseek-ai/dsh-client-ui-primitives'
-import type { QueueAction, QueueItemId } from '../contract/queue.ts'
 import { NS } from '../locales.ts'
 import css from './QueueDock.module.css'
 
+const QUEUE_PREVIEW_CHARS = 200
+const EMPTY_QUEUE = [] as const
+
+function previewOf(content: InboxState['next-turn'][number]['content']): string {
+  const flat = content
+    .map(block => (block.type === 'text' ? block.text : `[${block.type}]`))
+    .join(' ').replace(/\s+/g, ' ').trim()
+  const chars = Array.from(flat)
+  return chars.length > QUEUE_PREVIEW_CHARS ? `${chars.slice(0, QUEUE_PREVIEW_CHARS).join('')}…` : flat
+}
+
+function textOf(content: InboxState['next-turn'][number]['content']): string | null {
+  if (!content.every(block => block.type === 'text')) return null
+  return content.map(block => block.text).join('')
+}
+
 /** Queue operations injected by the session-scoped registration. */
 export interface QueueDockInjected {
-  updateQueue: (itemId: QueueItemId, action: QueueAction) => Promise<void>
+  updateQueue: (itemId: MessageId, action: QueueAction) => Promise<void>
   notify: (level: 'info' | 'error', text: string) => void
 }
 
@@ -23,13 +41,13 @@ export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDock
  * Queue strip: one item renders directly; multiple items default to a
  * collapsible count header; an empty queue renders nothing.
  */
-export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps) {
-  const inbox = useSession(s => s.queue)
-  const queue = useMemo(() => inbox.filter(row => row.placement === 'queued'), [inbox])
+export function QueueDock({ useSession, useProjection, updateQueue, notify, t }: QueueDockProps) {
+  const inbox = useProjection('inbox') as unknown as InboxState | undefined
+  const queue = inbox?.['next-turn'] ?? EMPTY_QUEUE
   const running = useSession(s => s.running)
   const queueMutable = useSession(s => s.subagent === null)
-  const [editing, setEditing] = useState<{ id: QueueItemId; text: string } | null>(null)
-  const [busy, setBusy] = useState<QueueItemId | null>(null)
+  const [editing, setEditing] = useState<{ id: MessageId; text: string } | null>(null)
+  const [busy, setBusy] = useState<MessageId | null>(null)
   const [collapsed, setCollapsed] = useState(true)
   const listId = useId()
 
@@ -45,7 +63,7 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps
   const listVisible = queue.length === 1 || expanded
 
   const applyAction = async (
-    itemId: QueueItemId,
+    itemId: MessageId,
     action: QueueAction,
     failure: string,
   ): Promise<boolean> => {
@@ -90,18 +108,19 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps
           </button>
         )}
         <ul id={listId} className={css.list} hidden={!listVisible}>
-          {listVisible && queue.map(row => (
-            <li key={row.id} className={css.row}>
+          {listVisible && queue.map((message) => {
+            const text = textOf(message.content)
+            return <li key={message.id} className={css.row}>
               {/* Single-item strip has no count header, so the row itself carries the queue glyph. */}
               {queue.length === 1 && <span className={css.lead} aria-hidden><IconQueueOutline14 /></span>}
-              {editing?.id === row.id
+              {editing?.id === message.id
                 ? (
                   <input
                     autoFocus
                     className={css.editor}
                     aria-label={t('queue.edit')}
                     value={editing.text}
-                    onChange={(event) => { setEditing({ id: row.id, text: event.currentTarget.value }) }}
+                    onChange={(event) => { setEditing({ id: message.id, text: event.currentTarget.value }) }}
                     onKeyDown={(event) => {
                       if (event.key === 'Escape') {
                         setEditing(null)
@@ -114,9 +133,9 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps
                     }}
                   />
                 )
-                : <span className={css.preview}>{projectUserText(row.preview, [])}</span>}
+                : <span className={css.preview}>{projectUserText(previewOf(message.content), [])}</span>}
               {queueMutable && <div className={css.actions}>
-                {editing?.id === row.id
+                {editing?.id === message.id
                   ? (
                     <>
                       <Tooltip label={t('queue.save')} side="bottom" delayMs={500}>
@@ -145,17 +164,17 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps
                   )
                   : (
                     <>
-                      <Tooltip label={t('queue.edit')} side="bottom" delayMs={500} disabled={row.text === null}>
+                      <Tooltip label={t('queue.edit')} side="bottom" delayMs={500} disabled={text === null}>
                         <button
                           type="button"
                           className={css.action}
                           aria-label={t('queue.edit')}
                           // Disabled buttons fire no hover events, so the
                           // unsupported hint stays a native title.
-                          title={row.text === null ? t('queue.edit.unsupported') : undefined}
-                          disabled={busy !== null || row.text === null}
+                          title={text === null ? t('queue.edit.unsupported') : undefined}
+                          disabled={busy !== null || text === null}
                           onClick={() => {
-                            if (row.text !== null) setEditing({ id: row.id, text: row.text })
+                            if (text !== null) setEditing({ id: message.id, text })
                           }}
                         >
                           <IconEditOutline16 size={14} />
@@ -169,7 +188,7 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps
                           disabled={busy !== null}
                           onClick={() => {
                             void applyAction(
-                              row.id,
+                              message.id,
                               { kind: 'remove' },
                               t('queue.removeFailed'),
                             )
@@ -187,7 +206,7 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps
                           disabled={busy !== null || !running}
                           onClick={() => {
                             void applyAction(
-                              row.id,
+                              message.id,
                               { kind: 'steer' },
                               t('queue.steerFailed'),
                             )
@@ -200,7 +219,7 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps
                   )}
               </div>}
             </li>
-          ))}
+          })}
         </ul>
       </div>
     </div>

+ 4 - 3
packages/client/ui-conversation/src/client/service.ts

@@ -14,10 +14,11 @@ import { bytesToBase64, randomUUID } from '@deepseek-ai/dsh-util-crypto'
 // error, so scope resolution goes through the sessions service (scopeOf
 // method) instead of the standalone helper.
 import type { ISessions, SessionFace } from '@deepseek-ai/dsh-api-session-controller/client'
+import type { QueueAction } from '@deepseek-ai/dsh-api-session-controller/types'
+import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { ImageMediaType } from '@deepseek-ai/dsh-attachment'
 import type { ComposerAttachment } from './contract/slots.ts'
-import type { QueueAction, QueueItemId } from './contract/queue.ts'
 import type { ComposerBlocks } from './contract/composer-blocks.ts'
 import type {
   DraftAttachmentId, SessionInputResolver, SubmitImageAttachment, SubmitOutcome,
@@ -49,7 +50,7 @@ export interface IConversation {
    * @param action - requested queue operation.
    * @returns completion; converged strict-steer races resolve, while other failures reject.
    */
-  updateQueue(itemId: QueueItemId, action: QueueAction): Promise<void>
+  updateQueue(itemId: MessageId, action: QueueAction): Promise<void>
   /**
    * Cancel the scoped session's in-flight turn while preserving its pending Queue.
    * @returns completion; failures reject as in send.
@@ -215,7 +216,7 @@ export class ConversationController extends Service implements IConversation {
   }
 
   /** Apply one operation to a pending queue occurrence. */
-  async updateQueue(itemId: QueueItemId, action: QueueAction): Promise<void> {
+  async updateQueue(itemId: MessageId, action: QueueAction): Promise<void> {
     const session = this.scopedSession('updateQueue')
     const result = await session.updateQueue(itemId, action)
     if (!result.ok) {

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

@@ -130,7 +130,7 @@ export function InputBar({
   const editorDisabled = removed || (locked && !workspaceTrigger)
   const editable = live && !locked && !machineBusy
   const canSteerQueue = !locked && !machineBusy && !commandMenuOpen && empty && running && subagent === null
-    && input.queue.some(row => row.placement === 'queued')
+    && input.queue.length > 0
 
   useEffect(() => {
     if (input === undefined || inputActions === undefined) return

+ 0 - 1
packages/client/ui-conversation/tests/conversation-registry.client.spec.ts

@@ -20,7 +20,6 @@ const SESSION_ID = 'resident' as SessionId
 function sessionSnapshot(): SessionSnapshot {
   return {
     sessionId: SESSION_ID,
-    queue: [],
     running: false,
     subagent: null,
     removed: false,

+ 13 - 23
packages/client/ui-conversation/tests/input-bar.client.spec.tsx

@@ -14,6 +14,8 @@ import { $getRoot, $isTextNode } from 'lexical'
 import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
 import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
 import type { SessionListState, SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client'
+import type { InboxState } from '@deepseek-ai/dsh-agent/types'
+import { createUserMessage } from '@deepseek-ai/dsh-llm'
 import { conversationSnapshot as conversationFixture, makeTranslate, sessionSnapshot as sessionFixture } from '@deepseek-ai/dsh-client-test-runtime'
 import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
 import type { Context } from '@deepseek-ai/cordis'
@@ -71,8 +73,8 @@ interface BenchOptions {
   workspacePickerOpen?: boolean
   onRequestWorkspace?: () => void
   promptError?: SessionSnapshot['promptError']
-  /** Authoritative queue rows served to the machine overlay (empty = none). */
-  queue?: SessionSnapshot['queue']
+  /** Pending next-turn messages served to the machine overlay (empty = none). */
+  queue?: InboxState['next-turn']
   /** The hub's steer-all face (empty-draft accelerated Enter). */
   steerQueue?: () => void
   variant?: 'hero' | 'composer'
@@ -90,12 +92,9 @@ interface BenchOptions {
   toggleCommandMenu?: (selection: { start: number; end: number }) => void
 }
 
-/** One pending queue row (the runtime snapshot shape, as the dock tests build it). */
-function row(id: string): SessionSnapshot['queue'][number] {
-  return {
-    id: id as never, messageId: `message-${id}` as never, placement: 'queued',
-    content: [{ type: 'text', text: id }], preview: id, text: id,
-  }
+/** One pending next-turn message. */
+function row(id: string): InboxState['next-turn'][number] {
+  return createUserMessage({ content: [{ type: 'text', text: id }], source: { kind: 'user' } })
 }
 
 /** Real machine behind the bar entry: sink spy, no slash pipeline (plain text goes straight to the sink). */
@@ -112,17 +111,17 @@ function bench(over?: BenchOptions) {
     subagent: over?.subagent ?? null,
     removed: over?.disabled ?? false,
     promptError: over?.promptError ?? null,
-    queue: over?.queue ?? [],
   }))
+  const inbox = createSnapshotStore<InboxState | undefined>({
+    'next-turn': over?.queue ?? [],
+    'next-step': [],
+  })
   type ShellDeps = ConstructorParameters<typeof SessionInputShell>[0]
   const shell = new SessionInputShell({
     actx: SCTX,
     defaultSink: sink,
+    inbox,
     commandImages: { serialize: () => Promise.resolve([]), release: () => {}, unsupportedNotice: (token: string) => `${token.trim()} images-unsupported` },
-    queue: {
-      getSnapshot: () => session.getSnapshot().queue,
-      subscribe: fn => session.subscribe(fn),
-    },
     ...(over?.steerQueue !== undefined ? { steerQueue: over.steerQueue } : {}),
     // Lexicon-only stub: adjudication untouched (undefined slash methods are
     // never reached — these benches drive plain-draft flows only).
@@ -528,7 +527,7 @@ describe('Enter semantics', () => {
     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 while idle, addressed to a subagent, on plain Enter, or with an empty queue', () => {
     // Idle: the gesture falls through to the machine's empty-draft no-op.
     const idle = bench({ queue: [row('q-1')], steerQueue: vi.fn() })
     fireEvent.keyDown(idle.textarea, { key: 'Enter', metaKey: true })
@@ -561,15 +560,6 @@ describe('Enter semantics', () => {
     expect(none.steerQueue).not.toHaveBeenCalled()
     expect(none.sink).not.toHaveBeenCalled()
 
-    // Pending steering rows are not the queue: nothing to flush.
-    const steering = bench({
-      running: true,
-      queue: [{ ...row('s-1'), placement: 'steering' }],
-      steerQueue: vi.fn(),
-    })
-    fireEvent.keyDown(steering.textarea, { key: 'Enter', metaKey: true })
-    expect(steering.steerQueue).not.toHaveBeenCalled()
-    expect(steering.sink).not.toHaveBeenCalled()
   })
 
   it('draft content outranks the queue: accelerated Enter steers the draft only', () => {

+ 53 - 30
packages/client/ui-conversation/tests/queue-dock.client.spec.tsx

@@ -7,8 +7,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
 import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
 import { useSyncExternalStore } from 'react'
 import type {
-  QueuedMessage, SessionListState, SessionSnapshot,
+  SessionListState, SessionSnapshot, UseProjection,
 } from '@deepseek-ai/dsh-api-session-controller/client'
+import type { InboxState } from '@deepseek-ai/dsh-agent/types'
+import type { UserMessage } from '@deepseek-ai/dsh-llm/types'
+import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
 import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
@@ -17,7 +20,6 @@ import {
 } from '@deepseek-ai/dsh-client-test-runtime'
 import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
 import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
-import type { QueueItemId } from '../src/client/contract/queue.ts'
 import type { InputState } from '../src/client/contract/input.ts'
 import { zh } from '../src/client/locales.ts'
 import { QueueDock, queueDockEntry, type QueueDockInjected, type QueueDockProps } from '../src/client/queue/QueueDock.tsx'
@@ -25,26 +27,35 @@ import { QueueDock, queueDockEntry, type QueueDockInjected, type QueueDockProps
 afterEach(cleanup)
 
 const SID = 's1' as SessionId
-const iid = (id: string): QueueItemId => id as QueueItemId
+const iid = (id: string): MessageId => id as MessageId
 
-function row(id: string, text: string | null, preview = text ?? '[image]'): QueuedMessage {
+function row(id: string, text: string | null, preview = text ?? '[image]'): UserMessage {
   return {
-    id: iid(id), messageId: `message-${id}` as never, placement: 'queued',
-    content: text === null ? [{ type: 'image', data: 'x' } as never] : [{ type: 'text', text }],
-    preview, text,
+    id: iid(id), role: 'user', source: { kind: 'user' },
+    content: text === null
+      ? [
+        ...(preview === '[image]' ? [] : [{ type: 'text' as const, text: preview.replace(/ \[image\]$/u, '') }]),
+        { type: 'image', data: 'x' } as never,
+      ]
+      : [{ type: 'text', text }],
   }
 }
 
-function snapshotWith(queue: QueuedMessage[]): SessionSnapshot {
+interface TestSnapshot extends SessionSnapshot {
+  readonly testInbox: InboxState
+}
+
+function snapshotWith(queue: UserMessage[], nextStep: UserMessage[] = []): TestSnapshot {
   return {
-    sessionId: SID, queue, running: true, removed: false, openState: 'open', openError: null,
+    sessionId: SID, running: true, removed: false, openState: 'open', openError: null,
     hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null,
     lastAgentError: null, promptAttempted: true, awaitingFirstTurn: false,
+    testInbox: { 'next-turn': queue, 'next-step': nextStep },
   }
 }
 
-/** Minimal live source backing the useSession stub. */
-function liveSession(initial: SessionSnapshot) {
+/** Minimal live source backing the Session and Inbox projection hooks. */
+function liveSession(initial: TestSnapshot) {
   let snapshot = initial
   const listeners = new Set<() => void>()
   const useSession: SnapshotSelectorHook<SessionSnapshot> = selector =>
@@ -55,9 +66,20 @@ function liveSession(initial: SessionSnapshot) {
       },
       () => selector(snapshot),
     )
+  const useProjection = ((
+    key: string,
+    selector: (value: InboxState | undefined) => unknown = value => value,
+  ) => useSyncExternalStore(
+    (listener) => {
+      listeners.add(listener)
+      return () => listeners.delete(listener)
+    },
+    () => selector(key === 'inbox' ? snapshot.testInbox : undefined),
+  )) as UseProjection
   return {
     useSession,
-    push(next: SessionSnapshot): void {
+    useProjection,
+    push(next: TestSnapshot): void {
       snapshot = next
       for (const listener of [...listeners]) listener()
     },
@@ -95,22 +117,21 @@ describe('QueueDock', () => {
   it('renders null while the queue is empty', () => {
     const snap = snapshotWith([])
     const source = liveSession(snap)
-    const { container } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
+    const { container } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} useProjection={source.useProjection} />)
     expect(container.innerHTML).toBe('')
   })
 
   it('leaves pending steering to the conversation flow', () => {
-    const steering = { ...row('s-1', 'interrupt'), placement: 'steering' as const }
-    const snap = snapshotWith([steering])
+    const snap = snapshotWith([], [row('s-1', 'interrupt')])
     const source = liveSession(snap)
-    const { container } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
+    const { container } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} useProjection={source.useProjection} />)
     expect(container.innerHTML).toBe('')
   })
 
   it('renders one row directly and defaults multiple rows to a collapsible count header', () => {
     const single = snapshotWith([row('i-1', 'one')])
     const source = liveSession(single)
-    const view = render(<QueueDock {...kitFor(single)} useSession={source.useSession} />)
+    const view = render(<QueueDock {...kitFor(single)} useSession={source.useSession} useProjection={source.useProjection} />)
     expect(view.queryByRole('button', { name: '1 条排队消息' })).toBeNull()
     expect(view.getByText('one')).toBeTruthy()
 
@@ -134,7 +155,7 @@ describe('QueueDock', () => {
   it('keeps an active single-row editor visible when another item arrives', () => {
     const single = snapshotWith([row('i-edit', 'before')])
     const source = liveSession(single)
-    const view = render(<QueueDock {...kitFor(single)} useSession={source.useSession} />)
+    const view = render(<QueueDock {...kitFor(single)} useSession={source.useSession} useProjection={source.useProjection} />)
 
     fireEvent.click(view.getByLabelText('编辑排队消息'))
     fireEvent.change(view.getByLabelText('编辑排队消息'), { target: { value: 'draft' } })
@@ -160,7 +181,7 @@ describe('QueueDock', () => {
     let finishUpdate: (() => void) | undefined
     const updateQueue = vi.fn(() => new Promise<void>((resolve) => { finishUpdate = resolve }))
     const view = render(
-      <QueueDock {...kitFor(single, { updateQueue })} useSession={source.useSession} />,
+      <QueueDock {...kitFor(single, { updateQueue })} useSession={source.useSession} useProjection={source.useProjection} />,
     )
 
     fireEvent.click(view.getByLabelText('删除排队消息'))
@@ -188,7 +209,7 @@ describe('QueueDock', () => {
   it('defaults a new multi-row queue to collapsed after the prior queue empties', () => {
     const first = snapshotWith([row('i-1', 'one'), row('i-2', 'two')])
     const source = liveSession(first)
-    const view = render(<QueueDock {...kitFor(first)} useSession={source.useSession} />)
+    const view = render(<QueueDock {...kitFor(first)} useSession={source.useSession} useProjection={source.useProjection} />)
     fireEvent.click(view.getByRole('button', { name: '2 条排队消息' }))
     expect(view.getByText('one')).toBeTruthy()
 
@@ -209,7 +230,9 @@ describe('QueueDock', () => {
       row('i-2', null, 'image [image]'),
     ])
     const source = liveSession(snap)
-    const { container, getByRole } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
+    const { container, getByRole } = render(
+      <QueueDock {...kitFor(snap)} useSession={source.useSession} useProjection={source.useProjection} />,
+    )
     fireEvent.click(getByRole('button', { name: '2 条排队消息' }))
     expect([...container.querySelectorAll('li')].map(item => item.textContent))
       .toEqual(['第一条排队消息', 'image [image]'])
@@ -228,7 +251,7 @@ describe('QueueDock', () => {
     const source = liveSession(snap)
     const updateQueue = vi.fn(() => Promise.resolve())
     const { getByLabelText, queryByLabelText } = render(
-      <QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} />,
+      <QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} useProjection={source.useProjection} />,
     )
 
     fireEvent.click(getByLabelText('编辑排队消息'))
@@ -252,7 +275,7 @@ describe('QueueDock', () => {
     const source = liveSession(snap)
     const updateQueue = vi.fn(() => Promise.resolve())
     const { getByLabelText, getByText } = render(
-      <QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} />,
+      <QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} useProjection={source.useProjection} />,
     )
 
     fireEvent.click(getByLabelText('编辑排队消息'))
@@ -271,7 +294,7 @@ describe('QueueDock', () => {
     const source = liveSession(snap)
     const updateQueue = vi.fn(() => Promise.resolve())
     const { getByLabelText } = render(
-      <QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} />,
+      <QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} useProjection={source.useProjection} />,
     )
 
     fireEvent.click(getByLabelText('编辑排队消息'))
@@ -289,7 +312,7 @@ describe('QueueDock', () => {
     const source = liveSession(snap)
     const updateQueue = vi.fn(() => Promise.resolve())
     const { getAllByLabelText, getByRole } = render(
-      <QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} />,
+      <QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} useProjection={source.useProjection} />,
     )
 
     fireEvent.click(getByRole('button', { name: '2 条排队消息' }))
@@ -304,7 +327,7 @@ describe('QueueDock', () => {
     const source = liveSession(running)
     const updateQueue = vi.fn(() => Promise.resolve())
     const rendered = render(
-      <QueueDock {...kitFor(running, { updateQueue })} useSession={source.useSession} />,
+      <QueueDock {...kitFor(running, { updateQueue })} useSession={source.useSession} useProjection={source.useProjection} />,
     )
 
     const button = rendered.getByLabelText('插话发送')
@@ -333,7 +356,7 @@ describe('QueueDock', () => {
     }
     const source = liveSession(snap)
     const view = render(
-      <QueueDock {...kitFor(snap)} useSession={source.useSession} />,
+      <QueueDock {...kitFor(snap)} useSession={source.useSession} useProjection={source.useProjection} />,
     )
 
     expect(view.getByText('pending child follow-up')).toBeTruthy()
@@ -348,7 +371,7 @@ describe('QueueDock', () => {
     const notify = vi.fn()
     const updateQueue = vi.fn(() => Promise.reject(new Error('transport failed')))
     const { getByLabelText, getByText } = render(
-      <QueueDock {...kitFor(snap, { updateQueue, notify })} useSession={source.useSession} />,
+      <QueueDock {...kitFor(snap, { updateQueue, notify })} useSession={source.useSession} useProjection={source.useProjection} />,
     )
 
     fireEvent.click(getByLabelText('插话发送'))
@@ -367,7 +390,7 @@ describe('QueueDock', () => {
     const notify = vi.fn()
     const updateQueue = vi.fn(() => Promise.reject(new Error('not found')))
     const { getByLabelText, getByText } = render(
-      <QueueDock {...kitFor(snap, { updateQueue, notify })} useSession={source.useSession} />,
+      <QueueDock {...kitFor(snap, { updateQueue, notify })} useSession={source.useSession} useProjection={source.useProjection} />,
     )
 
     fireEvent.click(getByLabelText('删除排队消息'))
@@ -380,7 +403,7 @@ describe('QueueDock', () => {
   it('follows authoritative retirement back to null', () => {
     const snap = snapshotWith([row('i-1', '在场')])
     const source = liveSession(snap)
-    const { container } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
+    const { container } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} useProjection={source.useProjection} />)
     expect(container.textContent).toContain('在场')
     act(() => { source.push(snapshotWith([])) })
     expect(container.innerHTML).toBe('')

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

@@ -6,7 +6,8 @@
 import { Context } from '@deepseek-ai/cordis'
 import { describe, expect, it, vi } from 'vitest'
 import { makeTranslate, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
-import type { QueuedMessage } from '@deepseek-ai/dsh-api-session-controller/client'
+import type { UserMessage } from '@deepseek-ai/dsh-llm/types'
+import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
 import { ComposerBlockRegistry } from '../src/client/input/blocks.ts'
 import { InputHub } from '../src/client/input/hub.ts'
 import { ConversationController, UnsupportedImageMediaTypeError } from '../src/client/service.ts'
@@ -132,20 +133,25 @@ describe('ConversationController', () => {
 })
 
 describe('InputHub queue steering (empty-draft accelerated Enter)', () => {
-  const row = (id: string): QueuedMessage => ({
-    id: id as never,
-    messageId: `message-${id}` as never,
-    placement: 'queued',
+  const row = (id: string): UserMessage => ({
+    id: id as MessageId,
+    role: 'user',
     content: [{ type: 'text', text: id }],
-    preview: id,
-    text: id,
+    source: { kind: 'user' },
+  })
+
+  const setInbox = (
+    runtime: SlotTestRuntime,
+    nextTurn: UserMessage[],
+    nextStep: UserMessage[] = [],
+  ) => runtime.sessions.setProjection('s1', 'inbox', {
+    'next-turn': nextTurn,
+    'next-step': nextStep,
   })
 
   it('steers every queued row in FIFO order and leaves steering rows alone', async () => {
     const b = await bench()
-    await b.runtime.sessions.updateSessionSnapshot('s1', (draft) => {
-      draft.queue = [row('q-1'), { ...row('q-2'), placement: 'steering' }, row('q-3')]
-    })
+    await setInbox(b.runtime, [row('q-1'), row('q-3')], [row('q-2')])
     b.shell.steerQueue()
     await vi.waitFor(() => {
       expect(b.updateQueue).toHaveBeenCalledTimes(2)
@@ -158,9 +164,7 @@ describe('InputHub queue steering (empty-draft accelerated Enter)', () => {
 
   it('converges silently when the turn closes or a row is claimed mid-steer', async () => {
     const b = await bench()
-    await b.runtime.sessions.updateSessionSnapshot('s1', (draft) => {
-      draft.queue = [row('q-1'), row('q-2')]
-    })
+    await setInbox(b.runtime, [row('q-1'), row('q-2')])
     // The turn closes before the second row: the flush stops, silently.
     b.updateQueue.mockResolvedValueOnce({
       ok: false, error: { code: 'steer-unavailable', message: 'closed', details: {} },
@@ -171,9 +175,7 @@ describe('InputHub queue steering (empty-draft accelerated Enter)', () => {
 
     // A row the host already claimed (e.g. a repeated empty-draft chord):
     // the duplicate strict steer is a silent no-op.
-    await b.runtime.sessions.updateSessionSnapshot('s1', (draft) => {
-      draft.queue = [row('q-3')]
-    })
+    await setInbox(b.runtime, [row('q-3')])
     b.updateQueue.mockResolvedValueOnce({
       ok: false, error: { code: 'queue-item-not-found', message: 'claimed', details: {} },
     } as never)
@@ -185,9 +187,7 @@ describe('InputHub queue steering (empty-draft accelerated Enter)', () => {
 
   it('surfaces one notice on a genuine steer failure and stops', async () => {
     const b = await bench()
-    await b.runtime.sessions.updateSessionSnapshot('s1', (draft) => {
-      draft.queue = [row('q-1'), row('q-2')]
-    })
+    await setInbox(b.runtime, [row('q-1'), row('q-2')])
     b.updateQueue.mockResolvedValueOnce({
       ok: false, error: { code: 'internal', message: 'broken', details: {} },
     } as never)

+ 0 - 1
packages/client/ui-session/tests/ui-session.client.spec.ts

@@ -75,7 +75,6 @@ function createSessionsBench(_ctx: Context): SessionsBench {
       const scopeCtx = new Context()
       const snapshot = createSnapshotStore<SessionSnapshot>({
         sessionId: id,
-        queue: [],
         running: false,
         subagent: null,
         removed: false,

+ 0 - 1
packages/client/ui-trajectory/tests/views.client.spec.tsx

@@ -110,7 +110,6 @@ function historySnapshot(
 function sessionSnapshot(nodes: LegacyConversationSlice['nodes']): SessionSnapshot {
   return {
     sessionId: SID,
-    queue: [],
     running: false,
     subagent: null,
     removed: false,

+ 0 - 1
packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx

@@ -27,7 +27,6 @@ type AttentionState = Parameters<Parameters<QuestionComposerProps['useSessionPen
 
 const sessionState: SessionState = {
   sessionId: SID,
-  queue: [],
   running: false,
   subagent: null,
   removed: false,

+ 0 - 1
packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx

@@ -26,7 +26,6 @@ type AttentionState = Parameters<Parameters<QuestionComposerProps['useSessionPen
 
 const sessionState: SessionState = {
   sessionId: SID,
-  queue: [],
   running: false,
   subagent: null,
   removed: false,

+ 5 - 1
packages/extensions/cordis-client-runner/src/client/api-catalog.ts

@@ -638,6 +638,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'PropsStore',
     declaration: 'export type PropsStore<H> = H extends StoreHandle<infer T, infer A> ? {\n    useStore: SnapshotSelectorHook<T>;\n    actions: BakedActions<T, A>;\n} : object;',
   },
+  {
+    name: 'QueueAction',
+    declaration: 'export type QueueAction = {\n    readonly kind: \'edit\';\n    readonly content: readonly ContentBlock[];\n} | {\n    readonly kind: \'remove\';\n} | {\n    readonly kind: \'steer\';\n};',
+  },
   {
     name: 'RemoteStream',
     declaration: 'export class RemoteStream<Item> implements AsyncIterable<RemoteStreamItem<Item>> {\n    constructor(private readonly connection: Pick<ConnectionHandle, \'hostDescription\'>, private readonly options: RemoteStreamOptions<Item>);\n    get signal(): AbortSignal;\n    restart(): void;\n    dispose(): Promise<void>;\n    [Symbol.asyncIterator](): AsyncIterator<RemoteStreamItem<Item>>;\n}',
@@ -708,7 +712,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'SessionSnapshot',
-    declaration: 'export interface SessionSnapshot {\n    readonly sessionId: SessionId;\n    readonly queue: readonly QueuedMessage[];\n    readonly running: boolean;\n    readonly subagent: {\n        readonly address: SubagentAddress;\n        readonly parentAvailable?: boolean;\n    } | null;\n    readonly removed: boolean;\n    readonly openState: OpenState;\n    readonly openError: ClientFailure | null;\n    readonly hasMore: boolean;\n    readonly loadingOlder: boolean;\n    readonly promptError: PromptError | null;\n    readonly blank: boolean;\n    readonly lastAgentError: string | null;\n    readonly promptAttempted: boolean;\n    readonly awaitingFirstTurn: boolean;\n}',
+    declaration: 'export interface SessionSnapshot {\n    readonly sessionId: SessionId;\n    readonly running: boolean;\n    readonly subagent: {\n        readonly address: SubagentAddress;\n        readonly parentAvailable?: boolean;\n    } | null;\n    readonly removed: boolean;\n    readonly openState: OpenState;\n    readonly openError: ClientFailure | null;\n    readonly hasMore: boolean;\n    readonly loadingOlder: boolean;\n    readonly promptError: PromptError | null;\n    readonly blank: boolean;\n    readonly lastAgentError: string | null;\n    readonly promptAttempted: boolean;\n    readonly awaitingFirstTurn: boolean;\n}',
   },
   {
     name: 'SessionStandardProps',

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

@@ -4430,6 +4430,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'PruneResult',
     declaration: 'export interface PruneResult {\n    readonly pruned: readonly PrunedEntry[];\n    readonly charsRemoved: number;\n}',
   },
+  {
+    name: 'QueueAction',
+    declaration: 'export type QueueAction = {\n    readonly kind: \'edit\';\n    readonly content: readonly ContentBlock[];\n} | {\n    readonly kind: \'remove\';\n} | {\n    readonly kind: \'steer\';\n};',
+  },
   {
     name: 'ReadFileLine',
     declaration: 'export interface ReadFileLine {\n    number: number;\n    text: string;\n}',
@@ -4640,11 +4644,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'SessionControlBaseline',
-    declaration: 'export interface SessionControlBaseline {\n    readonly queues: Readonly<Record<SessionId, readonly SessionQueuedItem[]>>;\n    readonly jobs: Readonly<Record<SessionId, readonly SessionJob[]>>;\n    readonly projections: Readonly<Record<SessionId, SessionProjectionBaseline>>;\n}',
+    declaration: 'export interface SessionControlBaseline {\n    readonly jobs: Readonly<Record<SessionId, readonly SessionJob[]>>;\n    readonly projections: Readonly<Record<SessionId, SessionProjectionBaseline>>;\n}',
   },
   {
     name: 'SessionControlFrame',
-    declaration: 'export type SessionControlFrame = {\n    readonly type: \'baseline\';\n    readonly value: SessionControlBaseline;\n} | {\n    readonly type: \'queue\';\n    readonly sessionId: SessionId;\n    readonly items: readonly SessionQueuedItem[];\n} | {\n    readonly type: \'jobs\';\n    readonly sessionId: SessionId;\n    readonly jobs: readonly SessionJob[];\n} | ({\n    readonly type: \'projection\';\n} & SessionProjectionUpdate);',
+    declaration: 'export type SessionControlFrame = {\n    readonly type: \'baseline\';\n    readonly value: SessionControlBaseline;\n} | {\n    readonly type: \'jobs\';\n    readonly sessionId: SessionId;\n    readonly jobs: readonly SessionJob[];\n} | ({\n    readonly type: \'projection\';\n} & SessionProjectionUpdate);',
   },
   {
     name: 'SessionCreateRequest',
@@ -4862,10 +4866,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'SessionPromptValue',
     declaration: 'export interface SessionPromptValue {\n    readonly accepted: true;\n}',
   },
-  {
-    name: 'SessionQueuedItem',
-    declaration: 'export interface SessionQueuedItem {\n    readonly id: MessageId;\n    readonly placement: \'queued\' | \'steering\' | \'context\';\n    readonly message: {\n        readonly id: MessageId;\n        readonly content: readonly JsonValue[];\n    };\n}',
-  },
   {
     name: 'SessionRawArtifact',
     declaration: 'export interface SessionRawArtifact {\n    readonly meta: SessionHeader;\n    readonly filename: string;\n    readonly content: string;\n}',

+ 0 - 1
packages/test-support/client-runtime/src/fixtures.ts

@@ -65,7 +65,6 @@ export interface SessionFixture {
 export function sessionSnapshot(sessionId: SessionId): SessionSnapshot {
   return {
     sessionId,
-    queue: [],
     running: false,
     subagent: null,
     removed: false,

+ 11 - 0
packages/test-support/client-runtime/src/sessions.ts

@@ -251,6 +251,17 @@ export class TestSessions implements ISessions {
     await this.stabilize(() => { record.snapshot.update(mutate) })
   }
 
+  /**
+   * Publish one complete projection value through the fixture Session face.
+   * @param id - session id.
+   * @param key - registered projection key.
+   * @param value - complete value for that key.
+   */
+  async setProjection(id: string, key: string, value: unknown): Promise<void> {
+    const record = this.require(id)
+    await this.stabilize(() => { record.session.projections.set(key, value) })
+  }
+
   /**
    * Replace a Session's complete contiguous event window.
    * @param id - Session identity.

+ 3 - 0
pnpm-lock.yaml

@@ -2129,6 +2129,9 @@ importers:
       '@deepseek-ai/cordis':
         specifier: workspace:^
         version: link:../../../vendor/cordis
+      '@deepseek-ai/dsh-agent':
+        specifier: workspace:^
+        version: link:../../core/agent
       '@deepseek-ai/dsh-api-remotes':
         specifier: workspace:^
         version: link:../../api/remotes