Browse Source

Merge remote-tracking branch 'upstream/master' into issue-1424-goal-resume-activation

mektpoy 1 month ago
parent
commit
b9a4b682c4
100 changed files with 918 additions and 207 deletions
  1. 2 2
      .agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml
  2. 2 2
      .agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md
  3. 2 2
      .agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml
  5. 1 1
      .agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md
  6. 1 1
      .agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md
  7. 2 2
      .agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.i18n.yaml
  8. 2 0
      .agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.md
  9. 2 0
      .agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.zh.md
  10. 6 0
      .agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.i18n.yaml
  11. 31 0
      .agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md
  12. 31 0
      .agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md
  13. 6 0
      .agents/notes/implemented/bug-fix/2026-09-05-continuous-client-recovery.i18n.yaml
  14. 37 0
      .agents/notes/implemented/bug-fix/2026-09-05-continuous-client-recovery.md
  15. 37 0
      .agents/notes/implemented/bug-fix/2026-09-05-continuous-client-recovery.zh.md
  16. 2 2
      .agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.i18n.yaml
  17. 5 5
      .agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.md
  18. 5 5
      .agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.zh.md
  19. 1 1
      apps/cli/package.json
  20. 1 1
      apps/web/package.json
  21. 4 0
      apps/web/tests/chat-long-interactions.e2e.ts
  22. 38 16
      apps/web/tests/lifecycle-chrome.e2e.ts
  23. 16 3
      apps/web/tests/message-actions.e2e.ts
  24. 74 11
      apps/web/tests/queue-actions.e2e.ts
  25. 1 1
      apps/web/tests/queue-image.e2e.ts
  26. 2 2
      docs/config-catalog.i18n.yaml
  27. 21 2
      docs/config-catalog.md
  28. 22 3
      docs/config-catalog.zh.md
  29. 2 2
      docs/event-producer-consumer.i18n.yaml
  30. 1 1
      docs/event-producer-consumer.md
  31. 1 1
      docs/event-producer-consumer.zh.md
  32. 1 1
      package.json
  33. 1 1
      packages/acp/acp/package.json
  34. 2 2
      packages/api/gateway/README.i18n.yaml
  35. 1 1
      packages/api/gateway/README.md
  36. 1 1
      packages/api/gateway/README.zh.md
  37. 1 1
      packages/api/gateway/package.json
  38. 48 0
      packages/api/gateway/tests/gateway.client.spec.ts
  39. 1 1
      packages/api/remotes/package.json
  40. 1 1
      packages/api/session-controller/package.json
  41. 1 1
      packages/api/settings-controller/package.json
  42. 1 1
      packages/api/workspace-controller/package.json
  43. 1 1
      packages/attachment/attachment-local/package.json
  44. 1 1
      packages/attachment/attachment/package.json
  45. 1 1
      packages/boot/app-boot/package.json
  46. 1 1
      packages/boot/cmdline/package.json
  47. 1 1
      packages/bundle/acp-app/package.json
  48. 1 1
      packages/bundle/base/package.json
  49. 1 1
      packages/bundle/headless/package.json
  50. 1 1
      packages/bundle/sdk-app/package.json
  51. 1 1
      packages/bundle/sdk-minimal/package.json
  52. 1 1
      packages/bundle/web-app/package.json
  53. 2 2
      packages/client/connection/README.i18n.yaml
  54. 5 1
      packages/client/connection/README.md
  55. 5 1
      packages/client/connection/README.zh.md
  56. 1 1
      packages/client/connection/package.json
  57. 32 43
      packages/client/connection/src/client/connection.ts
  58. 8 5
      packages/client/connection/src/client/index.ts
  59. 9 1
      packages/client/connection/src/index.ts
  60. 44 0
      packages/client/connection/src/recovery-config.ts
  61. 45 1
      packages/client/connection/tests/client-apply.client.spec.ts
  62. 127 16
      packages/client/connection/tests/connection.client.spec.ts
  63. 34 3
      packages/client/connection/tests/node-half.host.spec.ts
  64. 32 0
      packages/client/connection/tests/recovery-config.client.spec.ts
  65. 4 0
      packages/client/connection/tsconfig.client.json
  66. 1 0
      packages/client/connection/tsconfig.host.json
  67. 1 1
      packages/client/file-upload/package.json
  68. 1 1
      packages/client/hmr/package.json
  69. 1 1
      packages/client/locale/package.json
  70. 1 1
      packages/client/modules/package.json
  71. 1 1
      packages/client/store/package.json
  72. 1 1
      packages/client/ui-agent-preset/package.json
  73. 1 1
      packages/client/ui-approval/package.json
  74. 1 1
      packages/client/ui-attachment/package.json
  75. 1 1
      packages/client/ui-brand-official/package.json
  76. 2 2
      packages/client/ui-chat/README.i18n.yaml
  77. 1 1
      packages/client/ui-chat/README.md
  78. 1 1
      packages/client/ui-chat/README.zh.md
  79. 1 1
      packages/client/ui-chat/package.json
  80. 12 3
      packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts
  81. 11 10
      packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts
  82. 1 1
      packages/client/ui-commands/package.json
  83. 2 2
      packages/client/ui-conversation/README.i18n.yaml
  84. 2 0
      packages/client/ui-conversation/README.md
  85. 2 0
      packages/client/ui-conversation/README.zh.md
  86. 1 1
      packages/client/ui-conversation/package.json
  87. 2 0
      packages/client/ui-conversation/src/client/locales.ts
  88. 11 0
      packages/client/ui-conversation/src/client/queue/QueueDock.module.css
  89. 36 2
      packages/client/ui-conversation/src/client/queue/QueueDock.tsx
  90. 36 1
      packages/client/ui-conversation/tests/queue-dock.client.spec.tsx
  91. 1 1
      packages/client/ui-deliverables/package.json
  92. 1 1
      packages/client/ui-directory-picker-browse/package.json
  93. 1 1
      packages/client/ui-directory-picker-native/package.json
  94. 1 1
      packages/client/ui-goal/package.json
  95. 1 1
      packages/client/ui-input-trigger/package.json
  96. 1 1
      packages/client/ui-jobs/package.json
  97. 1 1
      packages/client/ui-layout/package.json
  98. 1 1
      packages/client/ui-message-feedback/package.json
  99. 1 1
      packages/client/ui-model-selection/package.json
  100. 1 1
      packages/client/ui-permission-presets/package.json

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.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-09-client-conversation-node-assembly.md
-2026-08-09-client-conversation-node-assembly.md: 425975944f7b23575c4f716dee8754dc6b3ab54c
-2026-08-09-client-conversation-node-assembly.zh.md: 96439fa951cdc1d6abe397ee37661291c0f954a7
+2026-08-09-client-conversation-node-assembly.md: 50e11b04d6461e99cdddb8e5e2d94bf864228841
+2026-08-09-client-conversation-node-assembly.zh.md: 7be1cd1b7f356caf51b8003821369771b0a6e269

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md

@@ -278,7 +278,7 @@ Page size, record packing, the number of history loads, and RAF coalescing affec
 |---|---|---|---|
 | Inbox | `none` | No Node | Recompute next-step ID state along the Reader chain when prepend supplies earlier splices; next-turn creates no Chat Context |
 | Message | Immediate by default | `user`, `steering`, or `context` | Window-gap repair can reclassify the same message key |
-| Request Prompt | Immediate by default | One `system-prompt` for every header carrying a non-empty system field | A step's first header anchors before its request messages; a later same-step series anchors after its surface rewrite; prepend of the preceding header can correct a partial-window anchor |
+| Request Prompt | Immediate by default | One non-empty `system-prompt` for the initial request, each explicit series, or a real system change | A step's first header anchors before its request messages; prepend can hide a conservatively rendered resume after its preceding header proves the system unchanged |
 | Assistant | RAF for scalar chunks and packed runs, immediate for final, none for pure usage/finish | Same-key `assistant-step` with running/settled/interrupted status | Scalar and packed reducers are equivalent; Matches support fallback without `step/start`; Location close produces interruption presentation |
 | Tool | Immediate by default | One recursive `tool-call` root containing all `subCalls` | A result-only history window supports fallback; running→settled retains its key |
 | Command | Immediate by default | Ordinary `command` or integrated `manual-compaction` | Checkpoint arrival may change the anchor without changing the Context key |
@@ -291,7 +291,7 @@ Page size, record packing, the number of history loads, and RAF coalescing affec
 
 Inbox demonstrates that every Event can be a start-only instantaneous-state Context; not every business requires a start/update pair. Reader links each next-step state to the prior same-kind Context instead of inventing a lifecycle ID for the entire Inbox. The state itself shares immutable pending splice nodes and one current claimed-batch Set, while unconsumed next-turn input remains outside Conversation because no Chat or Trajectory classification reads it.
 
-Request Prompt demonstrates shared pure interpretation without shared target State: Chat and Trajectory call `inspectRequestPrompt()` from their own Definitions. The function canonicalizes the full header and classifies model-visible system/tool differences; each target then chooses its own output. Chat materializes every header carrying a non-empty system field, including `series` snapshots that repeat an unchanged header for an explicitly declared series or a post-replacement request, while Trajectory retains the complete request fact and its change classification. Ordinary append-only later Turns do not write another unchanged header. The first header in a Step follows the provider envelope rather than the header Event position: step one uses the owning Turn start and later steps use their Step start, placing the system field before the request's user-role messages; a later header in the same Step stays at its own Event after the surface rewrite that began the new series. When the preceding header is outside a partial window, a non-`initial` header stays at its own Event until prepend supplies that predecessor. Every header is a full snapshot, so a first loaded `resume`, `change`, or `series` header can render its system field without fabricating a comparison to unloaded history.
+Request Prompt demonstrates shared pure interpretation without shared target State: Chat and Trajectory call `inspectRequestPrompt()` from their own Definitions. The function canonicalizes the full header and classifies model-visible system/tool differences; each target then chooses its own output. Chat materializes a non-empty initial system field, a real system change, and each `series` snapshot that explicitly begins a message series or follows a surface replacement. An unchanged `resume` remains in Trajectory and reconstruction state but does not repeat the visible Chat row once its predecessor is loaded. Ordinary append-only later Turns do not write another unchanged header. The first header in a Step follows the provider envelope rather than the header Event position: step one uses the owning Turn start and later steps use their Step start, placing the system field before the request's user-role messages; a later header in the same Step stays at its own Event after the surface rewrite that began the new series. When the preceding header is outside a partial window, a non-`initial` header stays at its own Event and renders conservatively. Prepending an identical predecessor hides an unchanged resume without withdrawing its stable Node key; a real change remains visible. Every header is a full snapshot, so a first loaded `resume`, `change`, or `series` header can render its system field without fabricating a comparison to unloaded history ([resume presentation decision](../bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md)).
 
 Retry, Assistant, and Turn Tail demonstrate independent claims on one Event. Each Definition updates only its own State and produces its own atomic Chat Node.
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md

@@ -278,7 +278,7 @@ Chat `order` 的结构性变化仍可能重排当前可见 key;纯 data 更新
 |---|---|---|---|
 | Inbox | `none` | 不生成 Node | prepend 补前序 splice 时沿 Reader 链重算 next-step ID state;next-turn 不创建 Chat Context |
 | Message | 默认 immediate | `user`、`steering` 或 `context` | window gap 修复可让同一 message key 重新分类 |
-| Request Prompt | 默认 immediate | 每条带非空 system 字段的 header 都生成一个 `system-prompt` | Step 首条 header 锚定在请求消息之前;同 step 后续序列锚定在表层改写之后;prepend 补入前序 header 后可纠正部分窗口的锚点 |
+| Request Prompt | 默认 immediate | 初始请求、每个显式序列或真实 system 变化各生成一个非空 `system-prompt` | Step 首条 header 锚定在请求消息之前;prepend 补入前序 header 并证明 system 未变后,可隐藏此前保守渲染的 resume |
 | Assistant | scalar chunk 与 packed run 为 RAF,final immediate,纯 usage/finish 为 none | 同 key `assistant-step`,状态为 running/settled/interrupted | scalar 与 packed reducer 等价;缺 `step/start` 可先用 Matches fallback;Location close 生成中断表现 |
 | Tool | 默认 immediate | 一个递归 `tool-call` root,包含全部 `subCalls` | result-only 历史窗口可 fallback;running→settled 保持 key |
 | Command | 默认 immediate | 普通 `command` 或集成 `manual-compaction` | checkpoint 到达可改变 anchor,但不改变 Context key |
@@ -291,7 +291,7 @@ Chat `order` 的结构性变化仍可能重排当前可见 key;纯 data 更新
 
 Inbox 展示了“每条 Event 都是一个 start-only 瞬间态 Context”,不是所有业务都需要 start/update 配对。每个 next-step state 通过 Reader 与前一个同 kind Context 形成连续 fold,而非给整个 Inbox 人工制造生命周期 ID。state 自身共享不可变 pending splice 节点和一个当前 claimed-batch Set;未消费的 next-turn input 不进入 Conversation,因为 Chat 与 Trajectory 都不读取它来分类。
 
-Request Prompt 展示了如何在不共享 target State 的前提下共用纯解释逻辑:Chat 与 Trajectory 各自在自己的 Definition 中调用 `inspectRequestPrompt()`。该函数规范化完整 header,并判定面向模型的 system/tool 差异;随后每个 target 自行选择产物。Chat 会物化每条带非空 system 字段的 header,包括为显式声明的序列或表层替换后的请求重复未变 header 的 `series` 快照;Trajectory 则保留完整请求事实及其变化分类。普通的仅追加后续 Turn 不会再次写入未变 header。一个 Step 中的首条 header 遵循提供方信封,而不是 header Event 位置:step one 使用所属 Turn start,后续 step 使用各自的 Step start,把 system 字段放到该请求的 user-role 消息之前;同一 Step 的后续 header 保留在开启新序列的表层改写之后。部分窗口未包含前序 header 时,非 `initial` header 会保留在自身 Event,直到 prepend 补入该前序 header。每条 header 都是完整快照,因此已加载窗口中的首条 `resume`、`change` 或 `series` header 无需凭空构造与未加载历史的比较,也能渲染其 system 字段。
+Request Prompt 展示了如何在不共享 target State 的前提下共用纯解释逻辑:Chat 与 Trajectory 各自在自己的 Definition 中调用 `inspectRequestPrompt()`。该函数规范化完整 header,并判定面向模型的 system/tool 差异;随后每个 target 自行选择产物。Chat 会物化非空的初始 system 字段、真实 system 变化,以及每个显式开启消息序列或紧随表层替换的 `series` 快照。未变化的 `resume` 会留在 Trajectory 和重建状态中,但前序 header 已加载时不会重复可见的 Chat 行。普通的仅追加后续 Turn 不会再次写入未变 header。一个 Step 中的首条 header 遵循提供方信封,而不是 header Event 位置:step one 使用所属 Turn start,后续 step 使用各自的 Step start,把 system 字段放到该请求的 user-role 消息之前;同一 Step 的后续 header 保留在开启新序列的表层改写之后。部分窗口未包含前序 header 时,非 `initial` header 会保留在自身 Event 并保守渲染。prepend 补入相同的前序 header 后,内容未变的 resume 会隐藏但不撤回其稳定 Node key;真实变化仍然可见。每条 header 都是完整快照,因此已加载窗口中的首条 `resume`、`change` 或 `series` header 无需凭空构造与未加载历史的比较,也能渲染其 system 字段([resume 展示决策](../bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md))。
 
 Retry、Assistant 和 Turn Tail 展示了同一 Event 被多个 Definition 独立认领。每个 Definition 只更新自己的 State,最终分别生成原子 Chat Node。
 

+ 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: 10fdd9b256c27aadada97195c8dc5516b4485a43
-2026-08-18-session-history-and-event-transport.zh.md: bf110b5f1a2bea99f9aa086c66a616eddaa0a50e
+2026-08-18-session-history-and-event-transport.md: acb6321937ce1b1c67933752f3b93ac82ddcfe0d
+2026-08-18-session-history-and-event-transport.zh.md: 3828eb5cfe6c496c3d5c2d7085ac550fe94f3101

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

@@ -64,7 +64,7 @@ The browser's Client Remote plugin starts `RemoteStreamMuxClient` idempotently o
 
 The Host sends one RFC 6455 Ping control frame to every open mux socket at the configured `websocketHeartbeatIntervalMs` interval (two seconds by default). The browser replies with Pong at the protocol layer; neither control frame enters the Remote stream JSON union or changes Connection generation state. Before each Ping, the Host marks the socket as awaiting Pong and terminates it at the next interval if no Pong arrived.
 
-After an initial connection failure or the loss of a connected socket, open logical streams end their current physical generation with `RemoteStreamCarrierError`. `ConnectionController` owns the bounded exponential retry schedule; each attempt asks the mux to replace any candidate or active socket exactly once before reopening `$events`. A user-requested reconnect resets the attempt sequence and bypasses the delay through the same path ([decision](../feature/2026-08-28-web-connection-recovery-control.md)).
+After an initial connection failure or the loss of a connected socket, open logical streams end their current physical generation with `RemoteStreamCarrierError`. `ConnectionController` owns the continuous exponential retry schedule with a capped delay; each attempt asks the mux to replace any candidate or active socket exactly once before reopening `$events`. A user-requested reconnect resets the attempt sequence and bypasses the delay through the same path ([decision](../feature/2026-08-28-web-connection-recovery-control.md)).
 
 The browser's network-status events are inputs to the same Controller. `offline` withdraws the Connection generation and suspends automatic retries; the next `online` transition restarts the base backoff. These events never establish connectivity: only a fresh `$events` ready frame publishes a Connection generation.
 

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

@@ -64,7 +64,7 @@ API Proxy 不拥有 Session 或 Workspace Remote namespace,也不拥有 Host 
 
 Host 按配置的 `websocketHeartbeatIntervalMs` 间隔(默认 2 秒)向每条已打开的 mux socket 发送一个 RFC 6455 Ping 控制帧;浏览器在协议层回复 Pong。两种控制帧都不进入 Remote stream JSON union,也不改变 Connection generation 状态。每次 Ping 前,Host 把 socket 标记为等待 Pong;若到下一间隔仍未收到 Pong,Host 会终止该 socket。
 
-首次建连失败或已连接 socket 丢失后,已打开的 logical stream 会以 `RemoteStreamCarrierError` 结束当前物理 generation。`ConnectionController` 拥有有界的指数 retry 调度;每次尝试都要求 mux 恰好一次替换候选或活动 socket,再重开 `$events`。用户要求的重连通过同一路径重置 attempt 序列并跳过等待(见[决策](../feature/2026-08-28-web-connection-recovery-control.zh.md))。
+首次建连失败或已连接 socket 丢失后,已打开的 logical stream 会以 `RemoteStreamCarrierError` 结束当前物理 generation。`ConnectionController` 拥有持续且间隔封顶的指数 retry 调度;每次尝试都要求 mux 恰好一次替换候选或活动 socket,再重开 `$events`。用户要求的重连通过同一路径重置 attempt 序列并跳过等待(见[决策](../feature/2026-08-28-web-connection-recovery-control.zh.md))。
 
 浏览器网络状态事件是同一 Controller 的输入。`offline` 会撤回 Connection generation 并暂停自动 retry;下一次 `online` 转换会从基础退避档重新开始。这些事件不会建立连接;只有新的 `$events` ready 帧才会发布 Connection generation。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.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-26-local-submission-echoes.md
-2026-08-26-local-submission-echoes.md: 258df8f3f4bdd0441b714c7602273897d2d79fc7
-2026-08-26-local-submission-echoes.zh.md: 57a9523a1eaa399e596f0c8eb6f8913545976f87
+2026-08-26-local-submission-echoes.md: 0fdb6b65e6e682a955fab770a543e82715d56d90
+2026-08-26-local-submission-echoes.zh.md: e6ca68578507bf9dd6c7cc0e95f17b597f7a5a80

+ 2 - 0
.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.md

@@ -14,6 +14,8 @@ A multi-image prompt spent seconds in client serialization plus host admission b
 
 **Retirement is observation-driven with a one-frame delay; display dedupe is render-time and declarative.** The Session marks an echo observed when a durable `user/message` or queue occurrence with its rpcId arrives (append, window install, or control frame) and removes it one animation frame later — after the conversation assembly's frame, which was scheduled first. ChatView independently hides any echo whose rpcId appears among rendered user/steering nodes or queue rows, so within every render exactly one of echo/durable is visible regardless of store update order. An identified prompt failure, `abandon()`, or disposal retires the echo immediately as failed; the first settlement wins.
 
+**Queue actions require the observed Host identity.** Queued echoes display a sending status and disabled edit, remove, and steer buttons in the same positions as admitted rows. The collapsed dock retains that status in its header. Prompt acknowledgement alone carries no queue-item identity, so only the correlated Host row enables its applicable actions. This keeps slow admission visible without treating a local request id as a mutable Host queue item.
+
 **The composer commits optimistically.** Enter clears the draft, occurrence table, and undo history in one machine transaction and keeps phase `plain`; the send runs as a detached attempt (concurrent sends allowed; the single frozen in-flight slot remains command-only). Failed detached sends are restored together in submission order while the composer is empty or still contains the preceding automatic restoration; a user edit ends that restoration sequence. Draft images remain owned by the detached attempt through echo retirement, so Session scope disposal can release them after they have left the rail. An observed echo gives each preview URL to `HistoricalImageCache.seed` under the admitted reference. The cache exposes that preview synchronously, fetches the durable attachment, replaces the preview with the canonical URL, and revokes both URLs with their respective lifetimes. Direct subagent continuations do not register echoes because their transport assigns a different RPC identity and image input is unsupported.
 
 Client image encoding switched from the synchronous chunked-`btoa` loop to `FileReader.readAsDataURL` (native encode). The browser→host transport still ships one base64 JSON envelope; that remaining #2885 transport work is out of scope here.

+ 2 - 0
.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.zh.md

@@ -14,6 +14,8 @@
 
 **退休由观察驱动并延迟一帧;显示去重是渲染期的声明式规则。**Session 在带其 rpcId 的 durable `user/message` 或 queue occurrence 到达时(append、窗口安装或 control frame)标记回显为已观察,并在一个动画帧之后移除,晚于先注册的会话组装帧。ChatView 独立地隐藏 rpcId 出现在已渲染 user/steering 节点或 queue 行中的回显,因此无论 store 更新顺序如何,每一次渲染中回显与 durable 恰有一个可见。带标识的 prompt 失败、`abandon()` 或销毁使回显立即按 failed 退休;先到的 settlement 生效。
 
+**队列操作要求观察到 Host 身份。**排队回显显示发送状态,并在正式队列行的相同位置显示禁用的编辑、删除和插话按钮。折叠后的队列在标题栏保留该状态。单独的 prompt 确认不携带队列项身份,因此只有关联的 Host 队列行到达后才启用适用的操作。这使缓慢受理的状态可见,同时避免把本地请求 id 当作可修改的 Host 队列项。
+
 **Composer 乐观提交。**Enter 在一个 machine 事务里清空草稿、occurrence 表和撤销历史,phase 保持 `plain`;发送作为 detached attempt 运行,允许并发发送,唯一的冻结 in-flight 槽只留给命令。多个 detached 发送失败时,只要 composer 为空或仍是上一次自动还原的内容,就按提交顺序合并还原;用户编辑后停止这一轮自动还原。草稿图片由 detached attempt 持有到回显退休,因此图片离开 rail 后销毁 Session scope 仍能释放它们。回显以 observed 退休时,`HistoricalImageCache.seed` 把每个预览 URL 挂到 admitted 引用名下。缓存同步公开预览 URL,同时读取 durable 附件;读取完成后用规范化 URL 替换预览,并按各自生命周期撤销两个 URL。直接 subagent continuation 不注册回显,因为它的 transport 会分配另一个 RPC id,而且不支持图片输入。
 
 客户端图片编码从同步分块 `btoa` 循环换成 `FileReader.readAsDataURL`(原生编码)。browser→host 传输仍是一个 base64 JSON 整包;#2885 剩余的传输改造不在本决定范围内。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md
+2026-09-03-resume-headers-do-not-repeat-system-prompts.md: de5e509c9e57af35bc36c24004457f02b63fd99d
+2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md: 008e25d8d507aac8fbaec7230bad433732f0efd0

+ 31 - 0
.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md

@@ -0,0 +1,31 @@
+# Agent Note: Resume headers do not repeat system prompts
+
+Status: implemented
+
+English | [中文](2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md)
+
+## Problem
+
+Forking a Session copies the source history into the child. The child's first model request then records a `request/header` with reason `resume`, even when its system field is identical to the preceding copied header. Chat treated every resume header as a new display point, so continuing the fork showed a second `System prompt` row and suggested that the system prompt had been injected twice. The provider request still carried the system field once; the duplicate existed only in Chat presentation.
+
+## Decision
+
+The durable resume header records the request boundary needed for exact Session reconstruction. Chat compares that full header with the preceding loaded Request Prompt and displays a non-empty system prompt only for the initial request, an explicit message-series start, or a real system-field change. An unchanged resume does not create a visible repetition.
+
+A partial history window may begin with a non-initial header and lack the predecessor needed for comparison. Chat renders that system prompt conservatively. If prepend later supplies an identical predecessor, the existing request-prompt Node becomes hidden instead of being withdrawn; its key and page-lifetime anchor stay stable. A different system field remains visible.
+
+Trajectory exposes every request header and its classified changes. Chat presentation does not alter provider requests, Session events, or reconstruction.
+
+## Alternatives considered
+
+**Omit unchanged resume headers from the Session log.** Rejected: resume is a real request boundary, and removing it would make exact reconstruction depend on process history that the durable log does not contain.
+
+**Special-case only forked Sessions.** Rejected: an ordinary process resume has the same presentation semantics, and the request headers already contain the system fields needed for a direct comparison.
+
+**Keep the duplicate row as a lifecycle marker.** Rejected: `System prompt` describes model-visible request content, so using it to mark a loop restart incorrectly implies another prompt injection. Request lifecycle evidence remains available in Trajectory.
+
+## Consequences
+
+Continuing a fork or resuming a process with an unchanged system field leaves one visible `System prompt` row for the current message series. Explicit series starts and real system changes still repeat the row. A partial window can initially show a conservative row and hide it after older history loads, while retaining the same materialized Node.
+
+The unit regression covers initial, series, unchanged resume, system-change, and prepend cases. The Web recorded-session scenario contains an unchanged resume header and asserts that the settled Chat renders exactly one `System prompt` control.

+ 31 - 0
.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md

@@ -0,0 +1,31 @@
+# Agent Note: Resume header 不重复系统提示词
+
+Status: implemented
+
+[English](2026-09-03-resume-headers-do-not-repeat-system-prompts.md) | 中文
+
+## 问题
+
+fork Session 会把源会话历史复制到子会话。即使 system 字段与前一条被复制的 header 完全相同,子会话的第一个模型请求仍会记录一条 reason 为 `resume` 的 `request/header`。Chat 把每条 resume header 都视作新的展示点,因此继续 fork 会显示第二行`系统提示词`,让人误以为系统提示词被注入了两次。提供方请求实际仍只携带一次 system 字段;重复仅存在于 Chat 展示中。
+
+## 决策
+
+持久化 resume header 记录精确重建 Session 所需的请求边界。Chat 会把完整 header 与前一条已加载 Request Prompt 比较,只在初始请求、显式消息序列起点或真实 system 字段变化时显示非空系统提示词。内容未变的 resume 不创建可见的重复行。
+
+部分历史窗口可能以非初始 header 开头,因缺少前序 header 而无法比较。Chat 会保守渲染该系统提示词。如果 prepend 随后补入相同的前序 header,既有 request-prompt Node 会转为隐藏而不是被撤回;其 key 和页面生命周期内的 anchor 保持稳定。不同的 system 字段仍然可见。
+
+Trajectory 会展示每一条请求 header 及其变化分类。Chat 展示不会改变提供方请求、Session event 或重建行为。
+
+## 考虑过的替代方案
+
+**从 Session log 省略未变化的 resume header。** 否决:resume 是真实的请求边界,移除后精确重建将依赖持久日志未记录的进程历史。
+
+**只对 fork Session 做特殊处理。** 否决:普通进程恢复具有相同的展示语义,请求 header 已经包含可直接比较的 system 字段。
+
+**把重复行保留为生命周期标记。** 否决:`系统提示词`描述模型可见的请求内容,用它标记 loop 重启会错误暗示再次注入提示词。请求生命周期证据仍可在 Trajectory 中查看。
+
+## 后果
+
+继续 fork 或在 system 字段未变时恢复进程,当前消息序列只保留一行可见的`系统提示词`。显式序列起点与真实 system 变化仍会重复该行。部分窗口起初可以显示保守行,并在更早历史加载后将其隐藏,同时保留同一个已物化 Node。
+
+单元回归覆盖初始请求、显式序列、未变化 resume、system 变化与 prepend 场景。Web 录制会话场景包含一条未变化的 resume header,并断言稳定后的 Chat 只渲染一个`系统提示词`控件。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-05-continuous-client-recovery.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-05-continuous-client-recovery.md
+2026-09-05-continuous-client-recovery.md: 3a017f69d78f9155dd513d44eb87e4bf0bdc1f9d
+2026-09-05-continuous-client-recovery.zh.md: bc8b12d2ae4143ddb397334a76cdd65b49c32d96

+ 37 - 0
.agents/notes/implemented/bug-fix/2026-09-05-continuous-client-recovery.md

@@ -0,0 +1,37 @@
+# Agent Note: Continuous Client connection recovery
+
+Status: implemented
+
+English | [中文](2026-09-05-continuous-client-recovery.zh.md)
+
+## Problem
+
+A generation source can remain pending without reporting readiness or carrier failure. A warning alone leaves the Client waiting indefinitely. Finite automatic retries also leave a page disconnected after the Host recovers from a longer outage, even though the browser's network status never changes.
+
+## Decision
+
+[`ConnectionController`](../../../../packages/client/connection/src/client/connection.ts) owns both the readiness deadline and the continuous retry schedule. A handshake reports a slow Host after three seconds and aborts after fifteen seconds by default. The warning gives early feedback without discarding a Host that needs several seconds to become ready; the deadline bounds each attempt and logs the timeout when it cancels the generation. Warning and cancellation times are independent, so a shorter hard deadline does not require changing both fields; a warning scheduled after settlement is cancelled. Cancellation reaches the generation source, which must release its resources and settle before another source starts. A cancelled source's late ready callback cannot establish a generation.
+
+Retry caps grow from 500ms through 1s, 2s, 4s, and 8s to 10s, with the existing 50–100% jitter. Failures at the maximum cap continue retrying. Separating a maximum delay from a retry-count limit follows the distinction in [Socket.IO's Client options](https://socket.io/docs/v4/client-options/#reconnectionattempts), while DSH retains its existing Remote stream protocol and single scheduler. Gateway replaces the physical socket once for each Controller-requested attempt. Both a pending WebSocket candidate and an open socket without an opening ready frame can recover this way.
+
+The Host Connection plugin validates `recovery` in its configuration and injects the resolved, non-secret timing into each page through `webserver/index-inject`. The Client validates that bootstrap input before providing Connection; direct loop options may override it. Timer values must be positive integers within the browser timer range, and the backoff factor must be finite and at least one. The shared resolver explicitly rejects `NaN`, which range comparisons alone cannot exclude. A factor of one selects continuous fixed-cap retries. Changes to Host timing apply to subsequently loaded pages.
+
+The Settings indicator labels active recovery **Reconnecting** and keeps **Reconnect now** available. This decision supersedes the terminal retry policy in [Web connection recovery control](../feature/2026-08-28-web-connection-recovery-control.md). That note still owns manual recovery, browser offline suspension, the single-scheduler rule, and indicator presentation. A fresh `$events` ready frame alone establishes connectivity; domain streams retain their own baseline and cursor recovery.
+
+## Alternatives considered
+
+**Only reject the readiness wait.** The Controller still waits for source settlement before retrying. The deadline must cancel the source as well, otherwise the same pending work blocks recovery.
+
+**Cancel every handshake after three seconds.** This conflates slow-Host feedback with failure and repeatedly discards legitimate slower handshakes. Separate configurable warning and cancellation times preserve a bounded opportunity to finish.
+
+**Stop after a finite retry sequence.** A page cannot discover later Host recovery without another user or browser event. Capping the interval limits traffic while preserving automatic recovery.
+
+**Start another source without awaiting cancellation cleanup.** Overlapping generations can retain listeners and deliver obsolete events. The source's cancellation contract requires settlement; abandoning it does not establish cleanup.
+
+## Consequences
+
+Long outages retain one retry schedule and produce bounded-rate connection traffic until recovery, explicit stop, or browser offline suspension. A permanently invalid credential still requires user action; connection retries do not refresh credentials or replay unary mutations. Immediate readiness continues to reset backoff, and browser offline remains authoritative for suspension; stable-connection reset windows and local-transport exceptions are separate policy changes.
+
+## Testing
+
+Controller tests cover recovery beyond the former final tier, fixed-cap retries, slow readiness, deadline cancellation, delayed cleanup, late ready callbacks, and manual reconnect or stop during a handshake. Host and Client tests cover timing propagation, invalid input, and injection disposal. Gateway tests use the real Controller and event pump with scripted WebSockets to verify both stalled opening phases, physical replacement, and one recovery reset. The recorded-session Web lifecycle scenario covers automatic recovery past the former stop point, manual replacement of a stalled handshake, and localized recovery presentation.

+ 37 - 0
.agents/notes/implemented/bug-fix/2026-09-05-continuous-client-recovery.zh.md

@@ -0,0 +1,37 @@
+# Agent Note: Client 连接持续恢复
+
+Status: implemented
+
+[English](2026-09-05-continuous-client-recovery.md) | 中文
+
+## 问题
+
+generation source 可能一直挂起,既不报告就绪,也不报告载体失败。仅告警会让 Client 无限等待。有限次数的自动重试还会使页面在 Host 从较长中断恢复后继续保持断连,即使浏览器网络状态从未变化。
+
+## 决策
+
+[`ConnectionController`](../../../../packages/client/connection/src/client/connection.ts) 同时拥有握手就绪期限和持续重试调度。默认情况下,握手在三秒后报告 Host 响应缓慢,在十五秒后中止。告警提供早期反馈,同时保留需要数秒才能就绪的 Host;硬期限限制每次尝试的时长,并在取消 generation 时记录超时。告警与取消时间相互独立,因此缩短硬期限不需要同时修改两个字段;排定在握手结束之后的告警会被取消。取消会传到 generation source,后者必须释放资源并结束,下一 source 才能启动。已取消 source 迟到的 ready 回调不能建立 generation。
+
+重试上限从 500ms 开始,经过 1s、2s、4s、8s 增长到 10s,保留现有 50–100% 抖动。达到最大上限后失败仍继续重试。把最大延迟与重试次数上限分开,与 [Socket.IO Client 选项](https://socket.io/docs/v4/client-options/#reconnectionattempts)的区分一致;DSH 保留自身的 Remote stream 协议和唯一调度器。Controller 每要求一次尝试,Gateway 就替换一次物理 socket。仍在等待打开的 WebSocket 候选,以及已打开但未收到首个 ready 帧的 socket,都能通过此路径恢复。
+
+Host Connection 插件校验配置中的 `recovery`,通过 `webserver/index-inject` 把已解析且不含秘密的时序数据注入每个页面。Client 在提供 Connection 之前校验启动输入;直接传给循环的选项可覆盖这些值。定时器值必须是浏览器定时器范围内的正整数,退避因子必须是至少为一的有限数。共享解析器显式拒绝仅靠范围比较无法排除的 `NaN`。一表示以固定上限持续重试。Host 时序配置的变更适用于随后加载的页面。
+
+Settings 指示器把活动恢复标为**自动重连中**,并始终提供**立即重连**。本决策取代[Web 连接恢复控制](../feature/2026-08-28-web-connection-recovery-control.zh.md)中的终态重试策略。该记录仍拥有手动恢复、浏览器离线暂停、唯一调度器规则与指示器展示。只有新的 `$events` ready 帧才能建立连接状态;各域的 stream 保留各自的 baseline 与 cursor 恢复方式。
+
+## 考虑过的替代方案
+
+**仅拒绝就绪等待。** Controller 仍会等待 source 结束才重试。硬期限必须同时取消 source,否则同一个挂起的工作仍会阻塞恢复。
+
+**三秒后取消每次握手。** 这会把 Host 响应缓慢的反馈等同于失败,反复丢弃合法的较慢握手。分别配置告警与取消时间,可在有界时长内保留完成机会。
+
+**有限序列结束后停止重试。** 没有后续用户或浏览器事件,页面就无法发现 Host 已恢复。对间隔封顶可以限制流量,同时保留自动恢复。
+
+**不等待取消清理就启动另一个 source。** 重叠的 generation 可能保留 listener 并投递过时事件。source 的取消契约要求它结束;放弃等待不能证明清理完成。
+
+## 后果
+
+长时间中断期间保留唯一重试调度,并以有界频率产生连接流量,直到恢复、显式停止或浏览器离线暂停。永久无效的凭据仍需用户处理;连接重试不会刷新凭据,也不会重放一元修改操作。立即就绪仍会重置退避,浏览器离线仍决定暂停;稳定连接后的重置窗口和本地载体例外属于独立策略变更。
+
+## 测试
+
+Controller 测试覆盖超过原终档后的恢复、固定上限重试、慢就绪、期限取消、延迟清理、迟到 ready,以及握手期间手动重连或停止。Host 和 Client 测试覆盖时序传递、无效输入及注入销毁。Gateway 测试使用真实 Controller 和事件 pump,配合可编排的 WebSocket,验证两个握手挂起阶段、物理替换及唯一恢复 reset。录制 Session 的 Web 生命周期场景覆盖超过原停止点后的自动恢复、手动替换挂起握手,以及本地化恢复展示。

+ 2 - 2
.agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.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-28-web-connection-recovery-control.md
-2026-08-28-web-connection-recovery-control.md: 6fec265c8e166836a5ab9413f1612cdaa6461a1d
-2026-08-28-web-connection-recovery-control.zh.md: e45119b272c297a783a650b71743e4f81c1a1565
+2026-08-28-web-connection-recovery-control.md: 649c840b20450d0282d707b50902c5d82317f54c
+2026-08-28-web-connection-recovery-control.zh.md: 5d68c6acff96b23960f0ec60f54b56a98e70e74f

+ 5 - 5
.agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.md

@@ -10,17 +10,17 @@ The Web Client automatically rebuilt its Remote event generation and physical We
 
 ## Decision
 
-The Host sends WebSocket Ping control frames every two seconds by default through the existing validated `websocketHeartbeatIntervalMs` configuration. Before each Ping it marks the socket as awaiting Pong; a socket still awaiting Pong at the next interval is terminated. `ConnectionController` is the sole retry scheduler. Online transport failures enter jittered exponential backoff whose cap starts at 500ms, doubles through 1s, 2s, 4s, and 8s, and stops growing at 10s; the actual delay is 50–100% of the cap. The failed retry in the 10s tier ends automatic recovery and publishes `disconnected`. Each physical retry publishes `connecting`, writes one `retry #N` warning, asks Gateway mux to replace any candidate or active socket exactly once, and reopens the internal `$events` stream.
+The Host sends WebSocket Ping control frames every two seconds by default through the existing validated `websocketHeartbeatIntervalMs` configuration. Before each Ping it marks the socket as awaiting Pong; a socket still awaiting Pong at the next interval is terminated. `ConnectionController` is the sole retry scheduler. Online transport failures enter jittered exponential backoff whose cap starts at 500ms, doubles through 1s, 2s, 4s, and 8s, and stops growing at 10s; the actual delay is 50–100% of the cap. The [continuous recovery decision](../bug-fix/2026-09-05-continuous-client-recovery.md) supersedes this note's finite retry policy: attempts continue at the capped interval, and handshake readiness has a hard deadline. Each physical retry publishes `connecting`, writes one `retry #N` warning, asks Gateway mux to replace any candidate or active socket exactly once, and reopens the internal `$events` stream.
 
 The Client Connection service exposes the identity-stable `ctx.connection.state` observable and `ctx.connection.reconnect()`. Its snapshot is undefined until the first connection outcome, then carries `disconnected`, `connecting`, or `connected`; equivalent states do not notify. Manual reconnect interrupts the current generation or retry delay, resets the attempt number, and starts retry 1 immediately through the same physical and logical path as automatic recovery. The browser's `offline` event immediately aborts active connection work, publishes `disconnected`, and suspends automatic retries. The next `online` transition publishes `connecting`, resets the attempt number, and starts again at the 500ms backoff tier; duplicate events do not create another loop. A fresh `$events` ready frame, rather than `navigator.onLine`, proves Host connectivity. Logical streams continue to own their baseline, cursor, and replay semantics after the replacement generation.
 
 The [Web Client architecture](../architecture/2026-07-19-gui-web-client-architecture.md), [Remote event delivery](../architecture/2026-08-10-remote-event-delivery.md), and [Session event transport](../architecture/2026-08-18-session-history-and-event-transport.md) retain their broader ownership decisions; this note supersedes only their former retry timing.
 
-The Settings shell is a recovery-specific consumer and therefore injects Connection directly; ordinary feature code continues to use `ctx.remote`. Its private hooks compartment binds the state observable and reconnect command. The expanded sidebar renders `ConnectionIndicator` immediately to the right of Settings: `disconnected` is a pale-yellow **Disconnected** action, `connecting` stays yellow while one to three dots advance every 500ms independently of retry timing, and a recovered connection displays pale-green **Connected** for two seconds. Hover or keyboard focus on either yellow state changes only the text to **Reconnect now**; press feedback uses a small warning-color transition, and no native title tooltip is present. Every visible state reserves the widest localized label and uses fixed icon and left-aligned text columns, so state changes do not move or resize the control. Initial startup and uninterrupted healthy operation render nothing.
+The Settings shell is a recovery-specific consumer and therefore injects Connection directly; ordinary feature code continues to use `ctx.remote`. Its private hooks compartment binds the state observable and reconnect command. The expanded sidebar renders `ConnectionIndicator` immediately to the right of Settings: `disconnected` is a pale-yellow **Disconnected** action, `connecting` displays **Reconnecting** in yellow while one to three dots advance every 500ms independently of retry timing, and a recovered connection displays pale-green **Connected** for two seconds. Hover or keyboard focus on either yellow state changes only the text to **Reconnect now**; press feedback uses a small warning-color transition, and no native title tooltip is present. Every visible state reserves the widest localized label and uses fixed icon and left-aligned text columns, so state changes do not move or resize the control. Initial startup and uninterrupted healthy operation render nothing.
 
 ## Alternatives considered
 
-**Retry every two seconds without a terminal state.** Rejected because a long outage would create continuous connection traffic. The retained exponential policy retries quickly at first, becomes progressively quieter, and leaves a stable recovery action after the 10s tier fails.
+**Retry every two seconds without a terminal state.** Rejected because a long outage would create continuous connection traffic. The retained exponential policy retries quickly at first, becomes progressively quieter, and retains the recovery action during retries. The continuous recovery decision governs retries after the cap is reached.
 
 **Render a full-width `ConnectionBanner` at the top of the viewport.** Rejected because the status belongs beside the recovery action the user named, and a global overlay consumes unrelated page chrome. The primitive is the inline `ConnectionIndicator`; no `ConnectionBanner` compatibility export exists before the first tagged release.
 
@@ -30,7 +30,7 @@ The Settings shell is a recovery-specific consumer and therefore injects Connect
 
 ## Consequences
 
-Idle browser connections generate more frequent heartbeat traffic than the former default, while long outages stop generating connection attempts after the capped retry fails. Deployments may override the Host Ping interval. Gateway mux owns no second retry timer, so every `retry #N` warning corresponds to one Controller-requested physical attempt.
+Idle browser connections generate more frequent heartbeat traffic than the former default, while long outages retain capped connection attempts under the continuous recovery decision. Deployments may override the Host Ping interval. Gateway mux owns no second retry timer, so every `retry #N` warning corresponds to one Controller-requested physical attempt.
 
 A manual reconnect intentionally disrupts every logical Remote stream sharing the physical socket. Their existing generation supervisors restore state through fresh baselines or cursors, and one-way notifications remain non-replayed.
 
@@ -38,4 +38,4 @@ The connection state and browser-network input stay in the React-free transport
 
 ## Testing
 
-Connection and Gateway tests pin the two-second heartbeat and Pong deadline, exponential retry limits and logs, browser offline suspension and online reset, manual sequence reset, one socket replacement per requested attempt, state deduplication, listener isolation, and disposal. Component tests pin healthy-state absence, hover/action copy, the independent dot animation, click behavior, and the two-second success state. The assembled Web test drives browser offline/online transitions, failed WebSocket attempts, stable indicator geometry, manual recovery, and the success confirmation through the shipped application.
+Connection and Gateway tests pin the two-second heartbeat and Pong deadline, exponential retry caps and logs, browser offline suspension and online reset, manual sequence reset, one socket replacement per requested attempt, state deduplication, listener isolation, and disposal. Component tests pin healthy-state absence, hover/action copy, the independent dot animation, click behavior, and the two-second success state. The assembled Web test drives browser offline/online transitions, failed WebSocket attempts, stable indicator geometry, manual recovery, and the success confirmation through the shipped application.

+ 5 - 5
.agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.zh.md

@@ -10,17 +10,17 @@ Web Client 会在故障后自动重建 Remote event generation 与物理 WebSock
 
 ## Decision
 
-Host 默认通过既有且经过校验的 `websocketHeartbeatIntervalMs` 配置,每 2 秒发送一次 WebSocket Ping 控制帧。每次 Ping 前,它把 socket 标记为等待 Pong;到下一间隔仍未收到 Pong 的 socket 会被终止。`ConnectionController` 是唯一的 retry 调度器。在线状态下的传输失败进入带抖动的指数退避:上限从 500ms 开始,依次翻倍为 1s、2s、4s、8s,最终封顶 10s;实际延迟是上限的 50%–100%。10s 档的 retry 仍失败后,自动恢复结束并发布 `disconnected`。每次物理 retry 都发布 `connecting`、写一条 `retry #N` warning、要求 Gateway mux 恰好一次替换候选或活动 socket,再重开内部 `$events` stream。
+Host 默认通过既有且经过校验的 `websocketHeartbeatIntervalMs` 配置,每 2 秒发送一次 WebSocket Ping 控制帧。每次 Ping 前,它把 socket 标记为等待 Pong;到下一间隔仍未收到 Pong 的 socket 会被终止。`ConnectionController` 是唯一的 retry 调度器。在线状态下的传输失败进入带抖动的指数退避:上限从 500ms 开始,依次翻倍为 1s、2s、4s、8s,最终封顶 10s;实际延迟是上限的 50%–100%。[持续恢复决策](../bug-fix/2026-09-05-continuous-client-recovery.zh.md)取代本笔记的有限重试策略:达到间隔上限后仍继续尝试,握手就绪设有硬期限。每次物理 retry 都发布 `connecting`、写一条 `retry #N` warning、要求 Gateway mux 恰好一次替换候选或活动 socket,再重开内部 `$events` stream。
 
 Client Connection 服务暴露 identity 稳定的 `ctx.connection.state` observable 与 `ctx.connection.reconnect()`。snapshot 在首次连接结果前为 undefined,此后为 `disconnected`、`connecting` 或 `connected`;等价状态不触发通知。手动重连会中断当前 generation 或重试等待、重置 attempt 序号,并通过与自动恢复相同的物理和逻辑路径立即开始 retry 1。浏览器的 `offline` 事件会立即中断活动连接工作、发布 `disconnected` 并暂停自动 retry;下一次 `online` 转换会发布 `connecting`、重置 attempt 序号,并从 500ms 退避档重新开始;重复事件不会创建另一条循环。Host 是否可达由新的 `$events` ready 帧证明,而不是由 `navigator.onLine` 证明。替换 generation 建立后,各 logical stream 仍自行持有 baseline、cursor 与 replay 语义。
 
 [Web Client 架构](../architecture/2026-07-19-gui-web-client-architecture.zh.md)、[Remote 事件投递](../architecture/2026-08-10-remote-event-delivery.zh.md)和[会话事件传输](../architecture/2026-08-18-session-history-and-event-transport.zh.md)继续持有各自更宽的所有权决策;本笔记只取代其中原有的重试时序。
 
-Settings 外壳是恢复功能专用消费方,因此直接注入 Connection;普通功能代码仍使用 `ctx.remote`。它的私有 hooks compartment 绑定状态 observable 与重连命令。展开的侧边栏在 Settings 右侧渲染 `ConnectionIndicator`:`disconnected` 是浅黄色的**连接异常**操作;`connecting` 保持黄色,其中一至三个点每 500ms 前进一次,与 retry 时序无关;恢复后则以浅绿色显示**连接成功**并驻留 2 秒。鼠标悬浮或键盘聚焦任一黄色状态时只把文字改为**立即重连**;按压反馈采用轻微的警告色过渡,不使用原生 title tooltip。所有可见状态都为最宽的本地化文字预留空间,并使用固定的图标列和左对齐文字列,因此状态变化不会移动控件或改变其宽度。首次启动和未曾中断的健康连接都不渲染。
+Settings 外壳是恢复功能专用消费方,因此直接注入 Connection;普通功能代码仍使用 `ctx.remote`。它的私有 hooks compartment 绑定状态 observable 与重连命令。展开的侧边栏在 Settings 右侧渲染 `ConnectionIndicator`:`disconnected` 是浅黄色的**连接异常**操作;`connecting` 以黄色显示**自动重连中**,其中一至三个点每 500ms 前进一次,与 retry 时序无关;恢复后则以浅绿色显示**连接成功**并驻留 2 秒。鼠标悬浮或键盘聚焦任一黄色状态时只把文字改为**立即重连**;按压反馈采用轻微的警告色过渡,不使用原生 title tooltip。所有可见状态都为最宽的本地化文字预留空间,并使用固定的图标列和左对齐文字列,因此状态变化不会移动控件或改变其宽度。首次启动和未曾中断的健康连接都不渲染。
 
 ## Alternatives considered
 
-**固定每 2 秒重试且不进入终态。**不采用,因为长时间故障会持续产生连接流量。保留的指数策略先快速重试,再逐步降低频率,并在 10s 档失败后留下稳定的恢复操作。
+**固定每 2 秒重试且不进入终态。**不采用,因为长时间故障会持续产生连接流量。保留的指数策略先快速重试,再逐步降低频率,并在重试期间保留恢复操作。达到间隔上限后的重试由持续恢复决策规定。
 
 **在视口顶部渲染全宽 `ConnectionBanner`。**不采用,因为状态应放在用户指定的恢复操作旁,全局覆盖层还会占用无关页面界面框架。该原语是内联 `ConnectionIndicator`;首次标签发布前不存在 `ConnectionBanner` 兼容导出。
 
@@ -30,7 +30,7 @@ Settings 外壳是恢复功能专用消费方,因此直接注入 Connection;
 
 ## Consequences
 
-空闲浏览器连接的心跳流量会高于原默认值;长时间故障则在封顶档 retry 失败后停止产生连接尝试。部署仍可覆盖 Host Ping 间隔。Gateway mux 不拥有第二个 retry timer,因此每条 `retry #N` warning 都对应一次由 Controller 请求的物理尝试。
+空闲浏览器连接的心跳流量会高于原默认值;长时间故障则按持续恢复决策保留间隔封顶的连接尝试。部署仍可覆盖 Host Ping 间隔。Gateway mux 不拥有第二个 retry timer,因此每条 `retry #N` warning 都对应一次由 Controller 请求的物理尝试。
 
 手动重连会刻意中断共享物理 socket 的全部 logical Remote stream。它们既有的 generation supervisor 会通过新 baseline 或 cursor 恢复状态;单向通知仍不重放。
 
@@ -38,4 +38,4 @@ Settings 外壳是恢复功能专用消费方,因此直接注入 Connection;
 
 ## Testing
 
-Connection 与 Gateway 测试固定 2 秒心跳及 Pong deadline、指数 retry 上限与日志、浏览器离线暂停和在线重置、手动重置序列、每次请求只替换一个 socket、状态去重、listener 隔离与 dispose。组件测试固定健康状态下不显示、悬浮与操作文案、独立点动画、点击行为与 2 秒成功状态。组装 Web 测试通过随附浏览器应用驱动浏览器 offline/online 转换、失败的 WebSocket 尝试、稳定的指示器几何、手动恢复与成功确认。
+Connection 与 Gateway 测试固定 2 秒心跳及 Pong deadline、指数 retry 间隔上限与日志、浏览器离线暂停和在线重置、手动重置序列、每次请求只替换一个 socket、状态去重、listener 隔离与 dispose。组件测试固定健康状态下不显示、悬浮与操作文案、独立点动画、点击行为与 2 秒成功状态。组装 Web 测试通过随附浏览器应用驱动浏览器 offline/online 转换、失败的 WebSocket 尝试、稳定的指示器几何、手动恢复与成功确认。

+ 1 - 1
apps/cli/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh",
   "description": "dsh CLI: profile boot, plugin management, and the browser UI alias",
-  "version": "0.1.2-rc.1",
+  "version": "0.1.3-alpha.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
apps/web/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-web-frontend",
   "description": "Web application entry: vite build over the @deepseek-ai/dsh-client-web shell library; dist/ served by apps/cli's dsh web",
-  "version": "0.1.2-rc.1",
+  "version": "0.1.3-alpha.1",
   "publishConfig": {
     "access": "public"
   },

+ 4 - 0
apps/web/tests/chat-long-interactions.e2e.ts

@@ -173,6 +173,10 @@ describe('web e2e: long Chat interaction contract', () => {
 
   it.skipIf(MODE === 'record')('keeps heterogeneous rows and their actions bound to exact semantic identities', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-chat-long-interactions'))
+    await expect.poll(
+      () => scaffold.ctx.agents.get(SessionId(SESSION_ID)) !== undefined,
+      { timeout: 10_000 },
+    ).toBe(true)
     const source = scaffold.ctx.agents.get(SessionId(SESSION_ID))
     if (source === undefined) throw new Error('seeded long-history agent is not attached')
 

+ 38 - 16
apps/web/tests/lifecycle-chrome.e2e.ts

@@ -296,16 +296,15 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
     const recoveryTripwire = watchConsole(recoveryPage)
     const sockets: WebSocketRoute[] = []
     let rejectConnections = false
+    let holdConnections = false
     await recoveryPage.routeWebSocket('**/api/remote.mux', (route) => {
       sockets.push(route)
-      if (rejectConnections) {
-        void route.close({ code: 4001, reason: 'connection recovery test' })
-        return
-      }
+      if (rejectConnections || holdConnections) return
       route.connectToServer()
     })
     onTestFailed(() => saveFailureShot(recoveryPage, 'web-e2e-connection-recovery'))
     try {
+      await recoveryPage.clock.install()
       await recoveryPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
       await recoveryPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
       await expect.poll(() => sockets.length).toBe(1)
@@ -316,16 +315,16 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
         name: 'Disconnected, reconnect now', exact: true,
       })
       await offline.waitFor({ timeout: 2_000 })
-      await recoveryPage.waitForTimeout(750)
+      await recoveryPage.clock.fastForward(60_000)
       expect(sockets).toHaveLength(1)
 
       await recoveryPage.context().setOffline(false)
       await expect.poll(() => recoveryPage.evaluate(() => navigator.onLine)).toBe(true)
       const connecting = recoveryPage.getByRole('button', {
-        name: 'Connecting, restart now', exact: true,
+        name: 'Reconnecting automatically, reconnect now', exact: true,
       })
       await connecting.waitFor({ timeout: 10_000 })
-      expect(await connecting.innerText()).toMatch(/^Connecting\.{1,3}$/)
+      expect(await connecting.innerText()).toMatch(/^Reconnecting\.{1,3}$/)
       const connectingGeometry = await connectionIndicatorGeometry(connecting)
       expect(await connectionIndicatorTextAlignment(connecting)).toBe('left')
       await connecting.hover()
@@ -333,13 +332,21 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
       expect(await connectionIndicatorGeometry(connecting)).toEqual(connectingGeometry)
       await recoveryPage.mouse.move(0, 0)
 
-      await expect.poll(() => sockets.length, { timeout: 40_000 }).toBe(7)
-      const indicator = recoveryPage.getByRole('button', {
-        name: 'Disconnected, reconnect now', exact: true,
-      })
-      await indicator.waitFor({ timeout: 10_000 })
+      for (let count = 2; count <= 9; count++) {
+        await recoveryPage.clock.fastForward(10_000)
+        await expect.poll(() => sockets.length).toBe(count)
+        if (count === 2) {
+          await recoveryPage.clock.fastForward(1_000)
+          expect(sockets).toHaveLength(count)
+        }
+        await sockets.at(-1)!.close({ code: 4001, reason: 'connection recovery test' })
+        // Drain the close event's promise continuations before advancing the next retry timer.
+        await recoveryPage.evaluate(() => {})
+      }
+      const indicator = connecting
       expect(await connectionIndicatorGeometry(indicator)).toEqual(connectingGeometry)
       expect(await connectionIndicatorTextAlignment(indicator)).toBe('left')
+      await indicator.hover()
       const snapshot = await captureStableAria(recoveryPage, '[class*="footArea"]', scaffold.workspaceCwd)
       await compareOrRefreshGolden(CONNECTION_ERROR_EXPECTED, snapshot, MODE)
       const style = await indicator.evaluate((element) => {
@@ -362,6 +369,20 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
       expect(style.color).toBe(style.referenceColor)
       expect(await indicator.locator('svg').count()).toBe(1)
       expect(await indicator.getAttribute('title')).toBeNull()
+      rejectConnections = false
+      await recoveryPage.clock.fastForward(10_000)
+      await expect.poll(() => sockets.length).toBe(10)
+      const automaticRecovery = recoveryPage.getByRole('status')
+      await automaticRecovery.waitFor({ timeout: 10_000 })
+      expect(await automaticRecovery.innerText()).toBe('Connected')
+      await recoveryPage.clock.fastForward(2_000)
+      await automaticRecovery.waitFor({ state: 'detached' })
+
+      holdConnections = true
+      await sockets.at(-1)!.close({ code: 4001, reason: 'manual recovery test' })
+      await connecting.waitFor()
+      await recoveryPage.clock.fastForward(500)
+      await expect.poll(() => sockets.length).toBe(11)
       const idleBackground = await indicator.evaluate(element => getComputedStyle(element).backgroundColor)
       await indicator.hover()
       expect(await indicator.innerText()).toBe('Reconnect now')
@@ -370,19 +391,20 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
       await recoveryPage.mouse.down()
       await expect.poll(() => indicator.evaluate(element => getComputedStyle(element).backgroundColor))
         .not.toBe(hoverBackground)
-      rejectConnections = false
+      holdConnections = false
       await recoveryPage.mouse.up()
 
-      await expect.poll(() => sockets.length).toBe(8)
+      await expect.poll(() => sockets.length).toBe(12)
       const recovered = recoveryPage.getByRole('status')
       await recovered.waitFor({ timeout: 10_000 })
       expect(await recovered.innerText()).toBe('Connected')
       expect(await connectionIndicatorGeometry(recovered)).toEqual(connectingGeometry)
       expect(await connectionIndicatorTextAlignment(recovered)).toBe('left')
+      await recoveryPage.clock.fastForward(2_000)
       await recovered.waitFor({ state: 'detached', timeout: 5_000 })
       expect(recoveryTripwire.pageErrors).toEqual([])
-      expect(recoveryTripwire.warnings.filter(warning => /connection lost, retry #[1-6]/i.test(warning)))
-        .toHaveLength(7)
+      expect(recoveryTripwire.warnings.filter(warning => /connection lost, retry #/i.test(warning)))
+        .toHaveLength(11)
     } finally {
       await recoveryPage.close()
     }

+ 16 - 3
apps/web/tests/message-actions.e2e.ts

@@ -1,7 +1,8 @@
 // Web e2e scenario: message IconActions + clocks. Cold-seeds a deterministic
-// completed-turn-tail fork case (zero model calls) and pins the settled
-// conversation aria after the footers are focus-revealed — the surface package
-// jsdom tests cannot substitute for (docs/testing.md snapshot rule).
+// completed-turn-tail fork case with an unchanged resume header (zero model
+// calls) and pins the settled conversation aria after the footers are
+// focus-revealed — the surface package jsdom tests cannot substitute for
+// (docs/testing.md snapshot rule).
 import { mkdir, readFile, writeFile } from 'node:fs/promises'
 import { join } from 'node:path'
 import { fileURLToPath } from 'node:url'
@@ -70,6 +71,10 @@ function completedTailFixture(raw: string): string {
       },
     }
   })
+  const inheritedHeader = kept.findLast(event => event.type === 'request/header')
+  if (inheritedHeader?.type !== 'request/header') {
+    throw new Error('borrowed recording has no request header')
+  }
   let seq = (kept.at(-1)?.seq ?? -1) + 1
   let time = (kept.at(-1)?.time ?? -1) + 1
   const at = (event: Record<string, unknown>): { seq: number; time: number } & Record<string, unknown> => ({
@@ -109,6 +114,7 @@ function completedTailFixture(raw: string): string {
       surfaceOp: 'append',
     }),
     at({ type: 'step/start', data: { turn: 2, step: 1 } }),
+    at({ type: 'request/header', data: { header: inheritedHeader.data.header, reason: 'resume' } }),
     at({
       type: 'assistant/message',
       data: {
@@ -149,6 +155,9 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
     await writeFile(join(sessionCwd, 'b.txt'), 'beta\n')
     const raw = completedTailFixture(await readFile(SEED, 'utf8'))
     expect(fixtureUserPrompts(raw), 'adapted seed must carry both prompts').toEqual([PROMPT, SECOND_PROMPT])
+    expect(parseSeedFixture(raw).events.flatMap(event => event.type === 'request/header'
+      ? [event.data.reason]
+      : []), 'adapted seed must carry an unchanged resume header').toEqual(['initial', 'resume'])
     await seedSession(scaffold, raw, SEED_ID)
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
@@ -172,6 +181,10 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
     await sessionRow.click()
     await expect.poll(() => page.getByText(MID_TURN_TEXT, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
     await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
+    await expect.poll(
+      () => page.getByRole('button', { name: 'System prompt', exact: true }).count(),
+      { timeout: 10_000 },
+    ).toBe(1)
 
     // Focus-reveal the footers (hover:hover keeps them opacity-hidden until
     // hover/focus-within). Branch renders only under assistant answers — user

+ 74 - 11
apps/web/tests/queue-actions.e2e.ts

@@ -26,6 +26,8 @@ const LAYOUT_EXPECTED = join(SNAPSHOT_DIR, 'layout.expected.md')
 const PRESERVED_EXPECTED = join(SNAPSHOT_DIR, 'preserved.expected.md')
 const PRESERVED_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'preserved-expanded.expected.md')
 const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
+const SENDING_EXPECTED = join(SNAPSHOT_DIR, 'sending.expected.md')
+const FAILED_EXPECTED = join(SNAPSHOT_DIR, 'failed.expected.md')
 const MODE = webSnapshotMode()
 
 const ACTIVE_PROMPT = 'Reply with a one-sentence description of event sourcing, then stop.'
@@ -34,6 +36,7 @@ const EDIT = 'Queue item to edit'
 const EDITED = 'Edited queue item'
 const TAIL = 'Queue item preserved after stop'
 const WAKE = 'Wake the preserved queue'
+const FAILED = 'Queue submission to retry'
 
 /** Durable turn-end classifications observed by the scenario. */
 function turnEndReasons(events: readonly SessionEvent[]): string[] {
@@ -93,15 +96,44 @@ describe('web e2e: queue row actions', () => {
     await input.press('Enter')
     await expect.poll(() => existsSync(readyFile), { timeout: 15_000 }).toBe(true)
 
-    for (const text of [REMOVE, EDIT]) {
-      // A just-submitted composer is read-only for the prompt round-trip.
-      await page.locator('[data-composer-input][contenteditable="true"]').first().waitFor({ timeout: 10_000 })
-      await input.fill(text)
+    const received = Promise.withResolvers<undefined>()
+    const release = Promise.withResolvers<undefined>()
+    await page.route('**/api/session/prompt', async (route) => {
+      received.resolve(undefined)
+      await release.promise
+      await route.continue()
+    }, { times: 1 })
+    try {
+      await input.fill(REMOVE)
       await input.press('Enter')
+      await received.promise
+      const pending = page.locator('[data-queue-dock] [data-submission-echo]')
+      await pending.getByRole('status').waitFor()
+      expect(await pending.getByRole('status').textContent()).toBe('Sending…')
+      expect(await pending.getByRole('button').count()).toBe(3)
+      expect(await pending.getByRole('button').evaluateAll(buttons =>
+        buttons.every(button => (button as HTMLButtonElement).disabled))).toBe(true)
+      expect(await input.textContent()).toBe('')
+      expect(await input.getAttribute('contenteditable')).toBe('true')
+      const sending = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
+      await compareOrRefreshGolden(SENDING_EXPECTED, sending, MODE)
+      await page.setViewportSize({ width: 390, height: 1000 })
+      await page.locator('[data-sidebar-collapsed="true"]').waitFor()
+      await expect.poll(() => pending.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true)
+      await page.setViewportSize({ width: 1680, height: 1000 })
+      await page.locator('[data-sidebar-collapsed="true"]').waitFor({ state: 'detached' })
+    } finally {
+      release.resolve(undefined)
     }
+    await expect.poll(() => page.getByRole('button', { name: 'Remove queued message' }).isEnabled()).toBe(true)
+    expect(await page.locator('[data-queue-dock] [data-submission-echo]').count()).toBe(0)
+    expect(await page.locator('[data-queue-dock]').getByRole('status').count()).toBe(0)
+    await input.fill(EDIT)
+    await input.press('Enter')
     const queueHeader = page.getByRole('button', { name: '2 queued messages' })
     await expect.poll(() => queueHeader.getAttribute('aria-expanded'), { timeout: 10_000 })
       .toBe('false')
+    await expect.poll(() => queueHeader.getByRole('status').count(), { timeout: 10_000 }).toBe(0)
     const collapsedSnapshot = await captureStableAria(
       page,
       '[class*="centerCol"]',
@@ -110,7 +142,7 @@ describe('web e2e: queue row actions', () => {
     await compareOrRefreshGolden(COLLAPSED_EXPECTED, collapsedSnapshot, MODE)
     await queueHeader.click()
     await expect.poll(
-      () => page.getByRole('button', { name: 'Remove queued message' }).count(),
+      () => page.getByRole('button', { name: 'Remove queued message', disabled: false }).count(),
       { timeout: 10_000 },
     ).toBe(2)
 
@@ -150,13 +182,36 @@ describe('web e2e: queue row actions', () => {
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
     expect(sessionEvents.filter(event => event.type === 'user/message' && event.data.source.kind === 'user')).toHaveLength(1)
-    expect(tripwire.pageErrors).toEqual([])
-    expect(tripwire.warnings).toEqual([])
+    await page.route('**/api/session/prompt', async (route) => {
+      const envelope = route.request().postDataJSON() as { rpcId: string }
+      await route.fulfill({
+        status: 200,
+        contentType: 'application/json',
+        json: {
+          type: 'server-response', rpcId: envelope.rpcId,
+          result: {
+            ok: false,
+            error: { code: 'session/agent-busy', message: 'Queue submission failed', details: { reason: 'admission refused' } },
+          },
+        },
+      })
+    }, { times: 1 })
+    await input.fill(FAILED)
+    await input.press('Enter')
+    const failure = page.getByRole('alert').filter({ hasText: 'Queue submission failed' })
+    await failure.waitFor()
+    await expect.poll(() => input.textContent()).toBe(FAILED)
+    expect(await page.locator('[data-queue-dock] [data-submission-echo]').count()).toBe(0)
+    const failed = [
+      await failure.ariaSnapshot(),
+      await captureStableAria(page, '[data-composer-card]', scaffold.workspaceCwd),
+    ].join('\n')
+    await compareOrRefreshGolden(FAILED_EXPECTED, failed, MODE)
 
     await input.fill(TAIL)
     await input.press('Enter')
     await expect.poll(
-      () => page.getByRole('button', { name: 'Remove queued message' }).count(),
+      () => page.getByRole('button', { name: 'Remove queued message', disabled: false }).count(),
       { timeout: 10_000 },
     ).toBe(2)
 
@@ -186,6 +241,8 @@ describe('web e2e: queue row actions', () => {
       ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
       : [])).toEqual([ACTIVE_PROMPT, EDITED, TAIL, WAKE])
     await expect.poll(() => page.locator('[data-queue-dock]').count()).toBe(0)
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
   }, 120_000)
 
   it.skipIf(MODE === 'record')('orders Todo before Goal and Queue on one responsive card column', async () => {
@@ -232,6 +289,7 @@ describe('web e2e: queue row actions', () => {
     const queueHeader = page.getByRole('button', { name: '2 queued messages' })
     await expect.poll(() => queueHeader.getAttribute('aria-expanded'), { timeout: 10_000 })
       .toBe('false')
+    await expect.poll(() => queueHeader.getByRole('status').count(), { timeout: 10_000 }).toBe(0)
 
     const layoutSnapshot = await captureStableAria(
       page,
@@ -241,9 +299,13 @@ describe('web e2e: queue row actions', () => {
     await compareOrRefreshGolden(LAYOUT_EXPECTED, layoutSnapshot, MODE)
 
     const expectAlignedContextPanels = async () => {
-      const queuePanelBox = await page.locator('[data-queue-dock] > div').boundingBox()
-      const todoBox = await page.locator('[data-testid="todo-panel"]').boundingBox()
-      const goalBox = await page.locator('[data-goal-bar] > div').boundingBox()
+      // Sample one layout: the responsive grid can move between separate browser round trips.
+      const [queuePanelBox, todoBox, goalBox] = await page.evaluate(() => [
+        '[data-queue-dock] > div', '[data-testid="todo-panel"]', '[data-goal-bar] > div',
+      ].map((selector) => {
+        const box = document.querySelector(selector)?.getBoundingClientRect()
+        return box === undefined ? null : { x: box.x, y: box.y, width: box.width }
+      }))
       expect(queuePanelBox).not.toBeNull()
       expect(todoBox).not.toBeNull()
       expect(goalBox).not.toBeNull()
@@ -282,6 +344,7 @@ describe('web e2e: queue row actions', () => {
       [
         'collapsed.expected.md', 'editing.expected.md', 'layout.expected.md',
         'preserved.expected.md', 'preserved-expanded.expected.md', 'ui.expected.md',
+        'sending.expected.md', 'failed.expected.md',
       ],
     )
   })

+ 1 - 1
apps/web/tests/queue-image.e2e.ts

@@ -105,7 +105,7 @@ describe('web e2e: queued image submission', () => {
     await dockThumb.waitFor({ timeout: 15_000 })
     await expect.poll(() => dockThumb.getAttribute('src')).toMatch(/^blob:/)
     await page.getByText(QUEUED_TEXT, { exact: true }).waitFor()
-    await page.getByRole('button', { name: 'Remove queued message' }).waitFor({ timeout: 15_000 })
+    await page.getByRole('button', { name: 'Remove queued message', disabled: false }).waitFor({ timeout: 15_000 })
     const queuedSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(QUEUED_EXPECTED, queuedSnapshot, MODE)
 

+ 2 - 2
docs/config-catalog.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/config-catalog.md
-config-catalog.md: af72b832723423745817fd340022e65c0c066d36
-config-catalog.zh.md: 6d961f22b3c29fbc4355e99c8de55823e014b74c
+config-catalog.md: 9bbdc705c14c0397e490ff63464021ab66154935
+config-catalog.zh.md: 2b3ae8417b0bf404301d12cabc203c16cc817cd3

+ 21 - 2
docs/config-catalog.md

@@ -318,8 +318,10 @@ Source: [`packages/shell/bash-sandbox/src/index.ts:35`](../packages/shell/bash-s
 Requires: `webServer` · `credentials`
 
 ```ts config-catalog
-/** Plugin config: the deployment's non-loopback serving authorities. */
+/** Browser authentication, request limits, and connection recovery configuration. */
 export interface ConnectionConfig {
+  /** Browser recovery timing, injected into each served page. */
+  recovery?: ConnectionRecoveryConfig
   /**
    * Authorities this deployment serves beyond loopback: exact `host:port`, or
    * port-less `host` matching any port. The /api trust fence refuses any
@@ -334,9 +336,26 @@ export interface ConnectionConfig {
   /** Maximum buffered JSON body for every `/api` request. Default: 300 MiB. */
   maxRequestBodyBytes?: number
 }
+
+/** Timing for generation readiness and automatic reconnection. */
+export interface ConnectionRecoveryConfig {
+  /** First-retry delay cap in ms; actual delay is 50–100% of the cap. Default: 500. */
+  backoffBaseMs?: number
+  /** Finite growth factor of at least 1 per failed attempt; 1 keeps a fixed cap. Default: 2. */
+  backoffFactor?: number
+  /** Maximum retry delay cap in ms; retries continue at this cap. Default: 10000. */
+  backoffMaxMs?: number
+  /**
+   * Delay before reporting a slow handshake, without cancelling it. Default: 3000.
+   * Omitted when readiness, failure, cancellation, or the hard deadline occurs first.
+   */
+  generationReadyWarnMs?: number
+  /** Deadline in ms for readiness, including physical connection setup. Default: 15000. */
+  generationReadyTimeoutMs?: number
+}
 ```
 
-Source: [`packages/client/connection/src/index.ts:71`](../packages/client/connection/src/index.ts)
+Source: [`packages/client/connection/src/index.ts:72`](../packages/client/connection/src/index.ts)
 
 <a id="deepseek-aidsh-client-hmr"></a>
 

+ 22 - 3
docs/config-catalog.zh.md

@@ -317,11 +317,13 @@ export type Config = LocalConfig
 
 ## `@deepseek-ai/dsh-client-connection`
 
-需要:`webServer` · `credentials`
+需要: `webServer` · `credentials`
 
 ```ts config-catalog
-/** Plugin config: the deployment's non-loopback serving authorities. */
+/** Browser authentication, request limits, and connection recovery configuration. */
 export interface ConnectionConfig {
+  /** Browser recovery timing, injected into each served page. */
+  recovery?: ConnectionRecoveryConfig
   /**
    * Authorities this deployment serves beyond loopback: exact `host:port`, or
    * port-less `host` matching any port. The /api trust fence refuses any
@@ -336,9 +338,26 @@ export interface ConnectionConfig {
   /** Maximum buffered JSON body for every `/api` request. Default: 300 MiB. */
   maxRequestBodyBytes?: number
 }
+
+/** Timing for generation readiness and automatic reconnection. */
+export interface ConnectionRecoveryConfig {
+  /** First-retry delay cap in ms; actual delay is 50–100% of the cap. Default: 500. */
+  backoffBaseMs?: number
+  /** Finite growth factor of at least 1 per failed attempt; 1 keeps a fixed cap. Default: 2. */
+  backoffFactor?: number
+  /** Maximum retry delay cap in ms; retries continue at this cap. Default: 10000. */
+  backoffMaxMs?: number
+  /**
+   * Delay before reporting a slow handshake, without cancelling it. Default: 3000.
+   * Omitted when readiness, failure, cancellation, or the hard deadline occurs first.
+   */
+  generationReadyWarnMs?: number
+  /** Deadline in ms for readiness, including physical connection setup. Default: 15000. */
+  generationReadyTimeoutMs?: number
+}
 ```
 
-来源:[`packages/client/connection/src/index.ts:71`](../packages/client/connection/src/index.ts)
+来源: [`packages/client/connection/src/index.ts:72`](../packages/client/connection/src/index.ts)
 
 <a id="deepseek-aidsh-client-hmr"></a>
 

+ 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: 20fcd611f2e5a1d890c1875def2779b2b3362244
-event-producer-consumer.zh.md: 03b7d371d47cc791638b1a173a602a5c05823ff0
+event-producer-consumer.md: 2e4abf739e302752b0fd9fb04ec078a2d790fa62
+event-producer-consumer.zh.md: a02968b7f0a9f970ff3da3b86763b926a1ced126

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

@@ -67,7 +67,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:181`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
 | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:189`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) |
 | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:85`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` |
-| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `inspector`, `modules` |
+| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules` |
 | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
 | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
 | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |

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

@@ -69,7 +69,7 @@
 | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:181`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
 | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:189`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) |
 | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:85`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` |
-| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `inspector`, `modules` |
+| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules` |
 | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
 | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
 | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-root",
-  "version": "0.1.2-rc.1",
+  "version": "0.1.3-alpha.1",
   "license": "MIT",
   "private": true,
   "type": "module",

+ 1 - 1
packages/acp/acp/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-acp",
   "description": "Automation-only Agent Client Protocol server for driving DeepSeek Harness agents over JSON-RPC stdio",
-  "version": "0.1.2-rc.1",
+  "version": "0.1.3-alpha.1",
   "publishConfig": {
     "access": "public"
   },

+ 2 - 2
packages/api/gateway/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/gateway/README.md
-README.md: 019aedfa1b900fd1e52744e91789b22c59d994e9
-README.zh.md: 5cf91d3f6c4f86333201209aabac9b0f9a58d703
+README.md: a6b9411165f0f5f458bbdc2d61f341925d406787
+README.zh.md: 4e9d8c72e2967bdd491b0ef6565af60802692796

+ 1 - 1
packages/api/gateway/README.md

@@ -49,7 +49,7 @@ Every unary call resolves to `RemoteResult<T>` — `{ ok: true, value }` or `{ o
 
 `ctx.remote.$stream()` returns a single-consumer `RemoteStream` spanning physical carrier generations. It permits one immediate retry while the Host remains available, otherwise waits for the next connected Host generation, and annotates each item with its physical generation. The domain consumer validates and accepts each generation's opening value; business and protocol failures remain terminal. Every terminal failure leaves this face as a `RemoteError`, including exhausted carrier retries and a generation that ends before its opening value, so a stream consumer discriminates the same way a unary caller does. `RemoteStreamCarrierError` names a retryable physical loss and reaches a domain only as the `carrierFailed` callback argument, never as a terminal outcome. `RemoteSnapshotStream` adds one opening snapshot followed by deltas. `RemoteJournalStream` adds follow-before-page opening, pagination, reconnect catch-up, and gap repair over domain-defined inclusive entry ranges; it removes complete duplicates and rejects gaps, inverted ranges, and partial overlaps. A domain may also carry cursorless notifications: they never advance or repair the durable cursor, and notifications received during gap repair publish only after the replacement page commits. If a newer generation supersedes that repair, held notifications from the superseded generation are discarded with its page. Disposing any stream cancels its requests and resolves after the active iterator is fully stopped.
 
-`ctx.remote.$on()` subscribes to one forwarded Host event. Its legal keys are exactly the Host assembly's forwarding selection, and the listener type is the owning package's own Cordis `Events` declaration, so no second signature can drift from it. Each subscription belongs to the calling fiber and disappears with it. The Client Remote service registers the `$events` pump as a Connection generation source when it activates, whether any `$on` listener exists. Browsers use Remote mux, while in-process compositions use `connection.rpc.open`; the opening `ready` item establishes a Connection generation and supplies its Host facts. Carrier failure, Remote stream failure, unexpected normal completion, a non-ready opening item, or a malformed event item ends that generation and lets Connection reopen it under bounded jittered exponential backoff. Ordinary notifications run in registration order and isolate listener failures. Agent-scoped waterfalls let a listener return a result, call `next()`, or reject; Gateway returns that outcome through the existing HTTP unary carrier.
+`ctx.remote.$on()` subscribes to one forwarded Host event. Its legal keys are exactly the Host assembly's forwarding selection, and the listener type is the owning package's own Cordis `Events` declaration, so no second signature can drift from it. Each subscription belongs to the calling fiber and disappears with it. The Client Remote service registers the `$events` pump as a Connection generation source when it activates, whether any `$on` listener exists. Browsers use Remote mux, while in-process compositions use `connection.rpc.open`; the opening `ready` item establishes a Connection generation and supplies its Host facts. Carrier failure, Remote stream failure, unexpected normal completion, a non-ready opening item, or a malformed event item ends that generation and lets Connection reopen it under continuous, capped jittered exponential backoff. Ordinary notifications run in registration order and isolate listener failures. Agent-scoped waterfalls let a listener return a result, call `next()`, or reject; Gateway returns that outcome through the existing HTTP unary carrier.
 
 `ctx.remote` exposes no Connection lifecycle control. A consumer whose responsibility includes recovery reads `ctx.connection.state` and calls `ctx.connection.reconnect()` directly; ordinary Remote consumers stay on generated namespaces and `$stream()`. The [connection recovery decision](../../../.agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.md) owns this exception.
 

+ 1 - 1
packages/api/gateway/README.zh.md

@@ -49,7 +49,7 @@ Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source
 
 `ctx.remote.$stream()` 返回跨越多个物理载体代次的单消费方 `RemoteStream`。Host 仍在线时,它允许一次立即重试;Host 离线时,它等待下一代连接,并为每个流项标注物理代次。领域消费方校验并接受各代次的 opening value;业务与协议错误仍然终止流。一切终态失败离开本面时都是 `RemoteError`,包括重试耗尽和在 opening value 之前就结束的代次,因此流消费方与一元调用方用同一种方式判别。`RemoteStreamCarrierError` 命名的是可重试的物理丢失,它只作为 `carrierFailed` 回调参数到达领域,绝不作为终态结果。`RemoteSnapshotStream` 在此之上规定每代由一个 opening snapshot 和后续 delta 组成。`RemoteJournalStream` 基于领域提供的 entry 闭区间提供 follow-before-page、分页、重连追赶与缺口修复;它丢弃完整重复项,并拒绝缺口、倒置区间和部分重叠。领域还可以携带无 cursor 的通知:通知绝不推进或修复持久 cursor,在缺口修复期间收到的通知只会在 replacement page 提交后发布。若更新代次取代该修复,旧代次 held notification 会与其 page 一同丢弃。dispose 任一种 stream 都会取消其请求,并在活动 iterator 完全停止后完成。
 
-`ctx.remote.$on()` 订阅一条被转发的 Host 事件。它的合法键恰好等于 Host 装配声明的转发选择,listener 类型就是事件所属包自己的 Cordis `Events` 声明,因此不存在会与之漂移的第二份签名。每个订阅归属调用方 fiber,并随该 fiber 一起消失。Client Remote 服务激活时就把 `$events` pump 注册为 Connection generation source,因此即使当前无 `$on` 订阅,它也会在 Connection 循环启动时打开。浏览器使用 Remote mux,进程内组合使用 `connection.rpc.open`;opening `ready` 项建立 Connection generation 并提供 Host 信息。物理 carrier 失败、Remote stream error、意外正常结束、非 ready 首项或畸形事件项都会终止该 generation,由 Connection 按有界且带抖动的指数退避重开。普通通知按注册顺序运行并隔离 listener 失败;Agent-scoped waterfall 允许 listener 返回结果、调用 `next()` 或拒绝,Gateway 再通过现有 HTTP 一元载体回送该结果。
+`ctx.remote.$on()` 订阅一条被转发的 Host 事件。它的合法键恰好等于 Host 装配声明的转发选择,listener 类型就是事件所属包自己的 Cordis `Events` 声明,因此不存在会与之漂移的第二份签名。每个订阅归属调用方 fiber,并随该 fiber 一起消失。Client Remote 服务激活时就把 `$events` pump 注册为 Connection generation source,因此即使当前无 `$on` 订阅,它也会在 Connection 循环启动时打开。浏览器使用 Remote mux,进程内组合使用 `connection.rpc.open`;opening `ready` 项建立 Connection generation 并提供 Host 信息。物理 carrier 失败、Remote stream error、意外正常结束、非 ready 首项或畸形事件项都会终止该 generation,由 Connection 按持续且间隔封顶的带抖动指数退避重开。普通通知按注册顺序运行并隔离 listener 失败;Agent-scoped waterfall 允许 listener 返回结果、调用 `next()` 或拒绝,Gateway 再通过现有 HTTP 一元载体回送该结果。
 
 `ctx.remote` 不暴露 Connection 生命周期控制。只有职责包含恢复的消费方才直接读取 `ctx.connection.state` 并调用 `ctx.connection.reconnect()`;普通 Remote 消费方仍只使用生成的 namespace 与 `$stream()`。[连接恢复决策](../../../.agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.zh.md)规定这项例外。
 

+ 1 - 1
packages/api/gateway/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-api-gateway",
   "description": "Typert Remote Host dispatcher and Client API endpoint",
-  "version": "0.1.2-rc.1",
+  "version": "0.1.3-alpha.1",
   "publishConfig": {
     "access": "public"
   },

+ 48 - 0
packages/api/gateway/tests/gateway.client.spec.ts

@@ -604,6 +604,54 @@ describe('Client Remote transport readiness', () => {
     })
   })
 
+  it.each([false, true])('replaces a stalled carrier and restores events (socket opened: %s)', async (autoOpen) => {
+    await withFakeWebSocket('https://harness.example', async () => {
+      vi.useFakeTimers()
+      vi.stubGlobal('__DSH_CONNECTION_RECOVERY__', {
+        backoffBaseMs: 10, backoffMaxMs: 10, generationReadyTimeoutMs: 100,
+      })
+      Object.assign(globalThis.location, { hostname: 'harness.example', search: '' })
+      const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+      FakeWebSocket.autoOpen = autoOpen
+      const ctx = new Context()
+      const reset = vi.fn()
+      ctx.on('connection/reset', reset)
+      try {
+        await ctx.plugin(TypertRegistry)
+        await ctx.plugin({ inject: [], apply: applyConnection })
+        await ctx.plugin({ inject, apply })
+        await vi.advanceTimersByTimeAsync(0)
+        const connection = ctx.get('connection') as ConnectionHandle
+        expect(FakeWebSocket.sockets).toHaveLength(1)
+        expect(connection.generation.getSnapshot()).toBeUndefined()
+        await vi.advanceTimersByTimeAsync(110)
+        expect(FakeWebSocket.sockets).toHaveLength(2)
+        expect(FakeWebSocket.sockets[0]?.readyState).toBe(FakeWebSocket.CLOSED)
+        expect(FakeWebSocket.sockets[0]?.closedWith).toHaveLength(1)
+        expect(reset).not.toHaveBeenCalled()
+        const replacement = FakeWebSocket.sockets[1]!
+        replacement.open()
+        await vi.advanceTimersByTimeAsync(0)
+        const opening = JSON.parse(replacement.sent[0]!) as { streamId: string }
+        replacement.receive({
+          type: 'item', streamId: opening.streamId,
+          value: { type: 'ready', clientId: 'recovered-client', host: { home: '/recovered' } },
+        })
+        await vi.advanceTimersByTimeAsync(0)
+        expect(connection.state.getSnapshot()).toBe('connected')
+        expect(connection.generation.getSnapshot()?.host.home).toBe('/recovered')
+        expect(reset).toHaveBeenCalledOnce()
+        expect(vi.getTimerCount()).toBe(0)
+      } finally {
+        await ctx.fiber.dispose()
+        await vi.advanceTimersByTimeAsync(0)
+        warnSpy.mockRestore()
+        vi.unstubAllGlobals()
+        vi.useRealTimers()
+      }
+    })
+  })
+
   it('does not replace an in-process carrier when Connection retries', async () => {
     const { client, start } = await benchFiber(
       vi.fn<ConnectionHandle['rpc']['call']>(),

+ 1 - 1
packages/api/remotes/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-api-remotes",
   "description": "Remote BFF assembly for application-selected Host capabilities",
-  "version": "0.1.2-rc.1",
+  "version": "0.1.3-alpha.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/api/session-controller/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-api-session-controller",
   "description": "Session Remote commands, cold reads, and live control transport",
-  "version": "0.1.2-rc.1",
+  "version": "0.1.3-alpha.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/api/settings-controller/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-api-settings-controller",
   "description": "Remote owner for the configuration surfaces over the settings-domain seams",
-  "version": "0.1.2-rc.1",
+  "version": "0.1.3-alpha.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/api/workspace-controller/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-api-workspace-controller",
   "description": "Workspace Remote commands and reconnect-safe state transport",
-  "version": "0.1.2-rc.1",
+  "version": "0.1.3-alpha.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/attachment/attachment-local/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-attachment-local",
   "description": "Private content-addressed DSH_HOME attachment storage",
-  "version": "0.1.2-rc.1",
+  "version": "0.1.3-alpha.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/attachment/attachment/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-attachment",
   "description": "Durable immutable attachment storage seam for the DeepSeek Harness",
-  "version": "0.1.2-rc.1",
+  "version": "0.1.3-alpha.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/boot/app-boot/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-app-boot",
   "description": "Shared boot glue for the app bins: .env loading, fail-loud Loader guards, snapshot-aware config resolution, and the Loader boot sequence",
-  "version": "0.1.2-rc.1",
+  "version": "0.1.3-alpha.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/boot/cmdline/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-cmdline",
   "description": "Immutable command-line handoff from a dsh launcher to any app plugin that injects cmdlineArgs",
-  "version": "0.1.2-rc.1",
+  "version": "0.1.3-alpha.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/bundle/acp-app/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-acp-app",
   "description": "The dsh ACP profile bundle: automation-only JSON-RPC stdio and process lifecycle over dsh-base",
-  "version": "0.1.2-rc.1",
+  "version": "0.1.3-alpha.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/bundle/base/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-base",
   "description": "The shared dsh core as a profile bundle: the first patch layer of base-backed profiles, inserting core rows over the empty profile root",
-  "version": "0.1.2-rc.1",
+  "version": "0.1.3-alpha.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/bundle/headless/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-headless",
   "description": "The dsh one-shot bundle: a direct core Agent/Session runner over dsh-base with no Host, HTTP, or browser layer",
-  "version": "0.1.2-rc.1",
+  "version": "0.1.3-alpha.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/bundle/sdk-app/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-sdk-app",
   "description": "The dsh SDK profile bundle: stdio JSON-RPC serving and process lifecycle over dsh-base",
-  "version": "0.1.2-rc.1",
+  "version": "0.1.3-alpha.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/bundle/sdk-minimal/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-sdk-minimal",
   "description": "The standalone minimal SDK profile bundle: JSON-RPC, one DeepSeek adapter, persistent shell, editor, and JSONL sessions",
-  "version": "0.1.2-rc.1",
+  "version": "0.1.3-alpha.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/bundle/web-app/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-web-app",
   "description": "The dsh browser-surface bundle: the web patch layer over dsh-base plus the runtime glue plugin (frontend dist serving, web-surface prompt, bash runtime variables, URL line)",
-  "version": "0.1.2-rc.1",
+  "version": "0.1.3-alpha.1",
   "publishConfig": {
     "access": "public"
   },

+ 2 - 2
packages/client/connection/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/connection/README.md
-README.md: 91e96a34d15463fd4246bf8fd8aee6112aaa54c4
-README.zh.md: fc6686e688170427528cb51db51e166f3c447f26
+README.md: b9f986a409b501bda644e2f230b0da51974c13aa
+README.zh.md: 4912259242f4032563019d26f0d5e6810cc13c7b

+ 5 - 1
packages/client/connection/README.md

@@ -43,7 +43,11 @@ Before authentication, every request still passes `src/api-request-trust.ts`. It
 
 API Gateway Client registers the internal `$events` logical stream as the sole generation source, independently of whether any `$on` listener exists. The Host attaches all incremental listeners in the API Remotes source factory, then sends one `{ type: 'ready', clientId, host: { home } }` item before events. `ConnectionController` publishes that generation and calls `onConnected` only after the ready item arrives, so baseline acquisition cannot race ahead of incremental observation.
 
-An ended `$events` stream, a Remote stream error, a non-ready opening item, or a malformed event item invalidates the current generation. While the browser reports network availability, the controller publishes `connecting` and retries with 50%–100% jitter under caps of 500ms, 1s, 2s, 4s, 8s, and 10s. It logs each attempt, asks Gateway to replace the physical WebSocket, and reopens `$events`; failure in the 10s tier publishes terminal `disconnected`. `ctx.connection.reconnect()` interrupts active work, resets the sequence, and starts retry 1 immediately. Browser `offline` aborts active work, publishes `disconnected`, and suspends automatic attempts; the next `online` transition resets the sequence and starts at the 500ms tier. A ready item publishes `connected`. The Gateway mux performs one physical connection attempt per request rather than running an independent retry schedule. The [connection recovery decision](../../../.agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.md) owns the cadence and manual recovery behavior.
+An ended `$events` stream, a Remote stream error, a non-ready opening item, or a malformed event item invalidates the current generation. A pending handshake logs a slow-Host warning after 3 seconds and logs the readiness timeout and aborts after 15 seconds by default, including time spent waiting for the physical socket. The source must stop delivery, release resources, and settle after cancellation before a replacement starts; late readiness from a cancelled source cannot publish a generation. While the browser reports network availability, the controller publishes `connecting` and retries with 50%–100% jitter under caps of 500ms, 1s, 2s, 4s, 8s, and 10s, continuing at the final cap until recovery. Every retry asks Gateway to replace the physical WebSocket once and reopens `$events`. The [continuous recovery decision](../../../.agents/notes/implemented/bug-fix/2026-09-05-continuous-client-recovery.md) owns the deadlines and retry policy.
+
+`ctx.connection.reconnect()` interrupts active work, resets the sequence, and starts retry 1 immediately. Browser `offline` aborts active work, publishes `disconnected`, and suspends automatic attempts; the next `online` transition resets the sequence and starts at the 500ms tier. Only a ready item publishes `connected`. Gateway mux owns no independent retry schedule.
+
+Set the Host Connection row's `config.recovery` to override retry caps, the growth factor, or handshake warning and cancellation times; the [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-client-connection) lists accepted fields. The Host validates these values and injects them into each served page. The Client validates the bootstrap data before providing Connection and uses those defaults when Gateway starts its loop; explicit `start()` timing overrides take precedence. The growth factor must be finite and at least one. Readiness, failure, cancellation, or a hard deadline that occurs before the warning cancels that warning. Reload the page after changing Host recovery configuration.
 
 <a id="model-experience"></a>
 ## Model Experience

+ 5 - 1
packages/client/connection/README.zh.md

@@ -43,7 +43,11 @@ cookie 签名密钥是 `ctx.credentials` 中由 `client-connection/browser-sessi
 
 API Gateway Client 把内部 `$events` logical stream 注册为唯一 generation source,与有无 `$on` 订阅无关。Host 在 API Remotes source factory 同步挂好所有增量 listener 后,先发送唯一 `{ type: 'ready', clientId, host: { home } }` 项,再发送事件。`ConnectionController` 仅在收到该 ready 项后发布 generation 并调用 `onConnected`,因此 baseline 不会跑在增量 listener 前面。
 
-`$events` 结束、返回 Remote stream error、收到非 ready 首项或畸形事件项,都会使当前 generation 失效。浏览器报告网络可用时,Controller 发布 `connecting`,并在 500ms、1s、2s、4s、8s 与 10s 上限内采用 50%–100% 抖动重试。它记录每次尝试、要求 Gateway 替换物理 WebSocket,再重开 `$events`;10s 档失败后发布终态 `disconnected`。`ctx.connection.reconnect()` 会中断活动工作、重置序列,并立即开始 retry 1。浏览器 `offline` 会中断活动工作、发布 `disconnected` 并暂停自动尝试;下一次 `online` 转换会重置序列并从 500ms 档开始。ready 项会发布 `connected`。Gateway mux 每次收到请求只做一次物理连接尝试,不再运行另一套重试调度。[连接恢复决策](../../../.agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.zh.md)规定重试节奏和手动恢复行为。
+`$events` 结束、返回 Remote stream error、收到非 ready 首项或畸形事件项,都会使当前 generation 失效。默认情况下,挂起的握手在 3 秒后记录 Host 响应缓慢告警,在 15 秒后记录就绪超时并中止,包含等待物理 socket 的时间。取消后,source 必须停止投递、释放资源并结束,替换 source 才能启动;已取消 source 迟到的 ready 不能发布 generation。浏览器报告网络可用时,Controller 发布 `connecting`,并在 500ms、1s、2s、4s、8s 与 10s 上限内采用 50%–100% 抖动重试,达到终档后继续尝试直到恢复。每次重试都要求 Gateway 替换一次物理 WebSocket,再重开 `$events`。[持续恢复决策](../../../.agents/notes/implemented/bug-fix/2026-09-05-continuous-client-recovery.zh.md)规定握手期限与重试策略。
+
+`ctx.connection.reconnect()` 会中断活动工作、重置序列,并立即开始 retry 1。浏览器 `offline` 会中断活动工作、发布 `disconnected` 并暂停自动尝试;下一次 `online` 转换会重置序列并从 500ms 档开始。只有 ready 项会发布 `connected`。Gateway mux 不拥有独立重试调度。
+
+可通过 Host Connection 行的 `config.recovery` 覆盖重试上限、增长因子或握手告警与取消时间;[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-client-connection)列出接受的字段。Host 校验这些值并注入每个已服务页面。Client 在提供 Connection 前校验启动数据,并在 Gateway 启动循环时采用这些默认值;显式传给 `start()` 的时序覆盖优先。增长因子必须是至少为一的有限数。若就绪、失败、取消或硬期限先于告警发生,该告警会被取消。修改 Host 恢复配置后需重新加载页面。
 
 <a id="model-experience"></a>
 ## 模型体验

+ 1 - 1
packages/client/connection/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-connection",
   "description": "Authenticated RPC transport, generation lifecycle, and browser fixture",
-  "version": "0.1.2-rc.1",
+  "version": "0.1.3-alpha.1",
   "publishConfig": {
     "access": "public"
   },

+ 32 - 43
packages/client/connection/src/client/connection.ts

@@ -1,3 +1,8 @@
+/** Connection generation readiness, cancellation, and continuous recovery. */
+import { resolveConnectionConfig, type ConnectionRecoveryConfig } from '../recovery-config.ts'
+
+export type { ConnectionRecoveryConfig } from '../recovery-config.ts'
+
 /** Stable Host facts delivered by one established Remote event generation. */
 export interface ConnectionHostInfo {
   /** Host account home used only to abbreviate displayed filesystem paths. */
@@ -12,25 +17,6 @@ export interface ConnectionGeneration {
   readonly host: ConnectionHostInfo
 }
 
-/** Reconnect/backoff tunables. All fields are optional; defaults are below. */
-export interface ConnectionConfig {
-  /** First-retry backoff cap in ms (jittered: actual delay is cap/2..cap). */
-  backoffBaseMs?: number
-  /** Exponential growth factor per failed attempt; values at or below 1 make the base tier final. */
-  backoffFactor?: number
-  /** Upper bound for the backoff cap in ms. */
-  backoffMaxMs?: number
-  /** Maximum wait for the registered generation source's ready signal. */
-  generationReadyTimeoutMs?: number
-}
-
-const CONNECTION_DEFAULTS: Required<ConnectionConfig> = {
-  backoffBaseMs: 500,
-  backoffFactor: 2,
-  backoffMaxMs: 10_000,
-  generationReadyTimeoutMs: 3_000,
-}
-
 const MANUAL_RECONNECT = new Error('connection: manual reconnect requested')
 const NETWORK_STATE_CHANGED = new Error('connection: browser network state changed')
 
@@ -72,7 +58,8 @@ export interface ConnectionSinks {
 /**
  * One long-lived source defining a Connection generation. The source must
  * attach its incremental listeners before calling `ready`, then remain pending
- * until the generation is lost or `signal` aborts.
+ * until the generation is lost or `signal` aborts. On abort it must stop
+ * delivery, release its resources, and settle before a replacement can start.
  * @param signal - cancellation for the current generation.
  * @param ready - one-shot report that incremental delivery is attached.
  * @returns a promise settling only when this generation ends or fails.
@@ -96,14 +83,14 @@ export class ConnectionController {
   private immediateRetry = false
   private networkAvailable = true
   private lastState: ConnectionState | undefined
-  private readonly config: Required<ConnectionConfig>
+  private readonly config: Required<ConnectionRecoveryConfig>
 
   constructor(
     private readonly source: ConnectionGenerationSource,
     private readonly sinks: ConnectionSinks = {},
-    config: ConnectionConfig = {},
+    config: ConnectionRecoveryConfig = {},
   ) {
-    this.config = { ...CONNECTION_DEFAULTS, ...config }
+    this.config = resolveConnectionConfig(config)
   }
 
   /** Idempotent: begin the connect/pump/reconnect loop. */
@@ -159,10 +146,9 @@ export class ConnectionController {
     return cap / 2 + Math.random() * (cap / 2)
   }
 
-  private isFinalBackoffTier(attempt: number): boolean {
-    const cap = this.backoffCap(attempt)
-    const nextCap = this.backoffCap(attempt + 1)
-    return cap >= this.config.backoffMaxMs || !Number.isFinite(nextCap) || nextCap <= cap
+  /** Re-read retry inputs after a potentially reentrant state sink. */
+  private isRetryInterrupted(immediate: boolean): boolean {
+    return this.immediateRetry || (!this.networkAvailable && !immediate)
   }
 
   /** Read through a method: stop() flips the flag across awaits, so narrowing from the loop condition must not stick. */
@@ -195,17 +181,10 @@ export class ConnectionController {
         this.immediateRetry = false
         if (immediate) this.attempt = 0
         manualAttempt = immediate
-        if (!immediate && this.attempt > 0 && this.isFinalBackoffTier(this.attempt)) {
-          const retryDelay = new AbortController()
-          this.retryDelay = retryDelay
-          this.emitState('disconnected')
-          await waitForAbort(retryDelay.signal)
-          if (this.retryDelay === retryDelay) this.retryDelay = null
-          continue
-        }
         const attempt = ++this.attempt
         this.emitState('connecting')
         if (!this.isRunning()) return
+        if (this.isRetryInterrupted(immediate)) continue
         if (!immediate) {
           const retryDelay = new AbortController()
           this.retryDelay = retryDelay
@@ -235,7 +214,7 @@ export class ConnectionController {
         rejectSourceLost = reject
       })
       const reportReady = (host: ConnectionHostInfo): void => {
-        if (sourceReady) return
+        if (sourceReady || gen !== this.generation || !this.isGenerationActive(ac)) return
         sourceReady = true
         resolveReady(host)
       }
@@ -267,7 +246,7 @@ export class ConnectionController {
 
       try {
         const host = await Promise.race([
-          waitForReady(ready, this.config.generationReadyTimeoutMs, ac.signal),
+          waitForReady(ready, this.config, ac.signal),
           sourceLost,
         ])
         if (ac.signal.aborted) throw new Error('generation aborted during readiness handshake')
@@ -277,8 +256,8 @@ export class ConnectionController {
         if (this.isGenerationActive(ac)) {
           this.callSink(() => { this.sinks.onConnected?.(host) })
         }
-      } catch {
-        // Source settlement and controller cancellation already abort the generation.
+      } catch (error) {
+        if (!ac.signal.aborted) ac.abort(error)
       }
 
       await failed
@@ -305,19 +284,29 @@ export class ConnectionController {
   }
 }
 
-/** Await source readiness while reporting, but not cancelling, a slow Host. */
-function waitForReady<T>(ready: Promise<T>, timeoutMs: number, signal: AbortSignal): Promise<T> {
+/** Report a slow handshake before the hard deadline ends its generation. */
+function waitForReady<T>(
+  ready: Promise<T>,
+  config: Required<ConnectionRecoveryConfig>,
+  signal: AbortSignal,
+): Promise<T> {
   return new Promise<T>((resolve, reject) => {
     let settled = false
+    const warning = setTimeout(() => {
+      console.warn(`[connection] generation is still not ready after ${String(config.generationReadyWarnMs)}ms`)
+    }, config.generationReadyWarnMs)
     const timeout = setTimeout(() => {
-      console.warn(`[connection] generation is still not ready after ${String(timeoutMs)}ms`)
-    }, timeoutMs)
+      const error = new Error(`connection generation was not ready within ${String(config.generationReadyTimeoutMs)}ms`)
+      console.warn(`[connection] ${error.message}; cancelling generation`)
+      finish({ error })
+    }, config.generationReadyTimeoutMs)
     const aborted = (): void => {
       finish({ error: new Error('connection generation aborted', { cause: signal.reason }) })
     }
     const finish = (outcome: { readonly value: T } | { readonly error: Error }): void => {
       if (settled) return
       settled = true
+      clearTimeout(warning)
       clearTimeout(timeout)
       signal.removeEventListener('abort', aborted)
       if ('error' in outcome) reject(outcome.error)

+ 8 - 5
packages/client/connection/src/client/index.ts

@@ -2,7 +2,7 @@
 import type { Context } from '@deepseek-ai/cordis'
 import {
   ConnectionController,
-  type ConnectionConfig,
+  type ConnectionRecoveryConfig,
   type ConnectionGeneration,
   type ConnectionGenerationSource,
   type ConnectionSinks,
@@ -12,6 +12,7 @@ import { createFixtureConnectionRpc } from './fixture.ts'
 import { createWebConnectionRpc, type RpcFetch, type RpcStreamOpen } from './rpc.ts'
 import { isLoopbackHostname } from '../loopback-hostname.ts'
 import type { ClientConnectionRpc } from '../rpc.ts'
+import { resolveConnectionConfig } from '../recovery-config.ts'
 
 declare module '@deepseek-ai/cordis' {
   interface Events {
@@ -39,7 +40,7 @@ export {
 // Connection loop types are public through ConnectionHandle.start; the
 // controller remains package-internal.
 export type {
-  ConnectionConfig,
+  ConnectionRecoveryConfig,
   ConnectionGeneration,
   ConnectionGenerationSource,
   ConnectionHostInfo,
@@ -101,6 +102,7 @@ export interface ClientTransportHooks {
 /** Page global carrying {@link ClientTransportHooks}; absent in the served web app. */
 interface ClientTransportGlobal {
   __DSH_TRANSPORT__?: ClientTransportHooks
+  __DSH_CONNECTION_RECOVERY__?: unknown
 }
 
 /**
@@ -133,10 +135,10 @@ export interface ConnectionHandle {
    * Start the connect/reconnect loop with the consumer's state callbacks.
    * API Gateway owns the loop; a second call throws.
    * @param sinks - connection-state callbacks.
-   * @param config - reconnect timing tunables.
+   * @param config - explicit timing overrides; omitted fields use Host bootstrap timing.
    * @returns lifecycle controls for the loop.
    */
-  start(sinks: ConnectionSinks, config?: ConnectionConfig): ConnectionLoop
+  start(sinks: ConnectionSinks, config?: ConnectionRecoveryConfig): ConnectionLoop
 }
 
 /** Controls retained by the sole owner of a running connection loop. */
@@ -182,6 +184,7 @@ export function apply(ctx: Context): void {
   const fixture = pageLocation !== undefined && new URLSearchParams(pageLocation.search).has('fixture')
   const fixtureRpc = fixture ? createFixtureConnectionRpc() : undefined
   const transport = (globalThis as ClientTransportGlobal).__DSH_TRANSPORT__
+  const recovery = resolveConnectionConfig((globalThis as ClientTransportGlobal).__DSH_CONNECTION_RECOVERY__)
   const rpc = fixtureRpc ?? createWebConnectionRpc(transport?.fetch, transport?.openStream)
   let generationSource: ConnectionGenerationSource | undefined
   let owner: ConnectionOwner | undefined
@@ -274,7 +277,7 @@ export function apply(ctx: Context): void {
           publishState(state)
           sinks.onStateChange?.(state)
         },
-      }, config ?? {})
+      }, { ...recovery, ...config })
       const current = { token, source, controller, stopNetworkWatch: watchBrowserNetwork(controller) }
       owner = current
       controller.start()

+ 9 - 1
packages/client/connection/src/index.ts

@@ -10,6 +10,7 @@ import { bridge, DEFAULT_MAX_REQUEST_BODY_BYTES } from './http-bridge.ts'
 import { assertTrustedAuthority } from './api-request-trust.ts'
 import { BrowserAuth } from './browser-auth.ts'
 import { HostConnectionService } from './rpc-host.ts'
+import { ConnectionRecoveryConfigSchema, resolveConnectionConfig, type ConnectionRecoveryConfig } from './recovery-config.ts'
 
 export type {
   ConnectionFetchMethod,
@@ -67,8 +68,10 @@ function assertImageBodyCapacity(ctx: Context, maxRequestBodyBytes: number): voi
 /** Services required before providing Connection. */
 export const inject = ['webServer', 'credentials']
 
-/** Plugin config: the deployment's non-loopback serving authorities. */
+/** Browser authentication, request limits, and connection recovery configuration. */
 export interface ConnectionConfig {
+  /** Browser recovery timing, injected into each served page. */
+  recovery?: ConnectionRecoveryConfig
   /**
    * Authorities this deployment serves beyond loopback: exact `host:port`, or
    * port-less `host` matching any port. The /api trust fence refuses any
@@ -85,6 +88,7 @@ export interface ConnectionConfig {
 }
 
 export const Config: z<ConnectionConfig> = z.object({
+  recovery: ConnectionRecoveryConfigSchema.default({}),
   trustedHosts: z.array(String).default([]),
   cookieMaxAgeDays: z.natural().min(1).default(30),
   maxRequestBodyBytes: z.natural().min(1).default(DEFAULT_MAX_REQUEST_BODY_BYTES),
@@ -98,6 +102,7 @@ export const Config: z<ConnectionConfig> = z.object({
  * @param config - resolved plugin config (schema defaults applied).
  */
 export async function apply(ctx: Context, config?: ConnectionConfig): Promise<void> {
+  const recovery = resolveConnectionConfig(config?.recovery)
   // The Loader resolves schema defaults; hand-built test contexts may pass none.
   const trustedHosts = config?.trustedHosts ?? []
   const cookieMaxAgeDays = config?.cookieMaxAgeDays ?? 30
@@ -111,6 +116,9 @@ export async function apply(ctx: Context, config?: ConnectionConfig): Promise<vo
     trustedHosts,
     await BrowserAuth.create(ctx.root, ctx.credentials, cookieMaxAgeDays),
   )
+  ctx.on('webserver/index-inject', (table) => {
+    table.push({ kind: 'global', name: '__DSH_CONNECTION_RECOVERY__', value: recovery })
+  })
   const fetchHandler = connection.createSharedFetchHandler(API_PATH)
   const route: WebRoute = {
     kind: 'prefix',

+ 44 - 0
packages/client/connection/src/recovery-config.ts

@@ -0,0 +1,44 @@
+/** Shared validation for Host-configured and browser-local connection recovery. */
+import z from '@deepseek-ai/schemastery'
+
+/** Timing for generation readiness and automatic reconnection. */
+export interface ConnectionRecoveryConfig {
+  /** First-retry delay cap in ms; actual delay is 50–100% of the cap. Default: 500. */
+  backoffBaseMs?: number
+  /** Finite growth factor of at least 1 per failed attempt; 1 keeps a fixed cap. Default: 2. */
+  backoffFactor?: number
+  /** Maximum retry delay cap in ms; retries continue at this cap. Default: 10000. */
+  backoffMaxMs?: number
+  /**
+   * Delay before reporting a slow handshake, without cancelling it. Default: 3000.
+   * Omitted when readiness, failure, cancellation, or the hard deadline occurs first.
+   */
+  generationReadyWarnMs?: number
+  /** Deadline in ms for readiness, including physical connection setup. Default: 15000. */
+  generationReadyTimeoutMs?: number
+}
+
+// Browsers and Node share this maximum signed 32-bit timer delay.
+const MAX_TIMER_MS = 2_147_483_647
+
+/** Schema shared by the Host plugin and the Client's recovery input parser. */
+export const ConnectionRecoveryConfigSchema: z<ConnectionRecoveryConfig> = z.object({
+  backoffBaseMs: z.natural().min(1).max(MAX_TIMER_MS).default(500),
+  backoffFactor: z.number().min(1).max(Number.MAX_VALUE).default(2),
+  backoffMaxMs: z.natural().min(1).max(MAX_TIMER_MS).default(10_000),
+  generationReadyWarnMs: z.natural().min(1).max(MAX_TIMER_MS).default(3_000),
+  generationReadyTimeoutMs: z.natural().min(1).max(MAX_TIMER_MS).default(15_000),
+})
+
+/**
+ * Validate recovery input and supply every timing default before starting work.
+ * @param config - Host configuration, page bootstrap data, or direct loop options.
+ * @returns validated, complete recovery timing.
+ */
+export function resolveConnectionConfig(config: unknown = {}): Required<ConnectionRecoveryConfig> {
+  const resolved = ConnectionRecoveryConfigSchema(config as ConnectionRecoveryConfig) as Required<ConnectionRecoveryConfig>
+  if (!Number.isFinite(resolved.backoffFactor)) {
+    throw new RangeError('connection recovery backoffFactor must be finite')
+  }
+  return resolved
+}

+ 45 - 1
packages/client/connection/tests/client-apply.client.spec.ts

@@ -71,6 +71,51 @@ async function mount(): Promise<ConnectionHandle> {
 }
 
 describe('connection client apply', () => {
+  it('uses Host bootstrap timing when Gateway starts without overrides', async () => {
+    vi.useFakeTimers()
+    vi.stubGlobal('__DSH_CONNECTION_RECOVERY__', {
+      backoffBaseMs: 10, backoffMaxMs: 10, generationReadyTimeoutMs: 20,
+    })
+    const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+    const handle = await mount()
+    const signals: AbortSignal[] = []
+    handle.registerGenerationSource(signal => new Promise<void>((resolve) => {
+      signals.push(signal)
+      signal.addEventListener('abort', () => { resolve() }, { once: true })
+    }))
+    const loop = handle.start({})
+    try {
+      await vi.advanceTimersByTimeAsync(20)
+      expect(signals[0]?.aborted).toBe(true)
+      expect(handle.state.getSnapshot()).toBe('connecting')
+      await vi.advanceTimersByTimeAsync(10)
+      expect(signals).toHaveLength(2)
+    } finally {
+      loop.stop()
+      await vi.advanceTimersByTimeAsync(0)
+      warnSpy.mockRestore()
+    }
+  })
+
+  it.each([{ generationReadyTimeoutMs: 0 }, { backoffFactor: NaN }])('rejects malformed bootstrap recovery before publishing the service: %j', (recovery) => {
+    vi.stubGlobal('__DSH_CONNECTION_RECOVERY__', recovery)
+    const ctx = new Context()
+    expect(() => { apply(ctx) }).toThrow()
+    expect(ctx.get('connection')).toBeUndefined()
+  })
+
+  it('rejects a NaN start override without acquiring the generation source', async () => {
+    const handle = await mount()
+    const source = vi.fn<ConnectionGenerationSource>()
+    const unregister = handle.registerGenerationSource(source)
+    try {
+      expect(() => handle.start({}, { backoffFactor: NaN })).toThrow(/backoffFactor.*finite/)
+      expect(source).not.toHaveBeenCalled()
+    } finally {
+      unregister()
+    }
+  })
+
   it('treats a runtime without browser location as local', async () => {
     delete (globalThis as Win).location
     expect((await mount()).isLoopback).toBe(true)
@@ -128,7 +173,6 @@ describe('connection client apply', () => {
       generations.push(handle.generation.getSnapshot()?.host.home)
     })
     expect(handle.generation.getSnapshot()).toBeUndefined()
-    // config omitted: the `config ?? {}` default arm is part of the surface.
     let connected = 0
     const loop = handle.start({ onConnected: () => { connected++ } })
     expect(() => handle.start({})).toThrow(/already owned by another consumer/)

+ 127 - 16
packages/client/connection/tests/connection.client.spec.ts

@@ -42,16 +42,21 @@ describe('connection lifecycle', () => {
     expect(source.activeCount).toBe(0)
   })
 
-  it('uses jittered exponential backoff and stops after the capped retry fails', async () => {
+  it('keeps retrying at the jittered cap and recovers after a prolonged outage', async () => {
     vi.useFakeTimers()
     const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0)
     const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
     const reconnectRequested = vi.fn()
     let calls = 0
+    let available = false
     const states: ConnectionState[] = []
-    const source: ConnectionGenerationSource = () => {
+    const source: ConnectionGenerationSource = (signal, ready) => {
       calls++
-      return Promise.reject(new Error('offline'))
+      if (!available) return Promise.reject(new Error('offline'))
+      ready({ home: '/h' })
+      return new Promise<void>((resolve) => {
+        signal.addEventListener('abort', () => { resolve() }, { once: true })
+      })
     }
     const controller = new ConnectionController(source, {
       onReconnectRequested: reconnectRequested,
@@ -71,9 +76,14 @@ describe('connection lifecycle', () => {
       expect(reconnectRequested).toHaveBeenCalledTimes(6)
       expect(warnSpy).toHaveBeenCalledTimes(6)
       expect(warnSpy).toHaveBeenLastCalledWith('[connection] connection lost, retry #6')
-      expect(states.at(-1)).toBe('disconnected')
+      expect(states).toEqual(['connecting'])
       await vi.advanceTimersByTimeAsync(60_000)
-      expect(calls).toBe(7)
+      expect(calls).toBe(19)
+      available = true
+      await vi.advanceTimersByTimeAsync(5_000)
+      expect(calls).toBe(20)
+      expect(states).toEqual(['connecting', 'connected'])
+      expect(reconnectRequested).toHaveBeenCalledTimes(19)
     } finally {
       controller.stop()
       randomSpy.mockRestore()
@@ -82,7 +92,7 @@ describe('connection lifecycle', () => {
     }
   })
 
-  it('treats a non-growing backoff as one terminal retry tier', async () => {
+  it('continues retrying at a fixed cap when the growth factor is one', async () => {
     vi.useFakeTimers()
     const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0)
     const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
@@ -103,9 +113,9 @@ describe('connection lifecycle', () => {
     try {
       await vi.advanceTimersByTimeAsync(5)
       expect(calls).toBe(2)
-      expect(states).toEqual(['connecting', 'disconnected'])
+      expect(states).toEqual(['connecting'])
       await vi.advanceTimersByTimeAsync(1_000)
-      expect(calls).toBe(2)
+      expect(calls).toBe(202)
     } finally {
       controller.stop()
       randomSpy.mockRestore()
@@ -211,6 +221,17 @@ describe('connection lifecycle', () => {
     }
   })
 
+  it('stops an offline wait when its state sink stops the controller synchronously', async () => {
+    const source = vi.fn<ConnectionGenerationSource>(() => Promise.resolve())
+    const controller = new ConnectionController(source, {
+      onStateChange: () => { controller.stop() },
+    })
+    controller.setNetworkAvailable(false)
+    controller.start()
+    await Promise.resolve()
+    expect(source).not.toHaveBeenCalled()
+  })
+
   it('allows one manual attempt while offline without starting automatic retries', async () => {
     vi.useFakeTimers()
     const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
@@ -243,7 +264,7 @@ describe('connection lifecycle', () => {
     }
   })
 
-  it('does not lose a reconnect requested synchronously from the terminal state sink', async () => {
+  it('does not delay a reconnect requested synchronously from the connecting state sink', async () => {
     vi.useFakeTimers()
     const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0)
     const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
@@ -254,7 +275,7 @@ describe('connection lifecycle', () => {
       return Promise.reject(new Error('offline'))
     }, {
       onStateChange: (state) => {
-        if (state !== 'disconnected' || !restart) return
+        if (state !== 'connecting' || !restart) return
         restart = false
         controller.reconnect()
       },
@@ -266,11 +287,10 @@ describe('connection lifecycle', () => {
     })
     controller.start()
     try {
-      await vi.advanceTimersByTimeAsync(5)
-      expect(calls).toBe(3)
+      await vi.advanceTimersByTimeAsync(0)
+      expect(calls).toBe(2)
       expect(warnSpy.mock.calls.map(([message]) => String(message))).toEqual([
         '[connection] connection lost, retry #1',
-        '[connection] connection lost, retry #1',
       ])
     } finally {
       controller.stop()
@@ -568,16 +588,16 @@ describe('connection lifecycle', () => {
     }
   })
 
-  it('reports but retains a generation whose source is slow to report ready', async () => {
+  it('accepts a slow Host after the warning and clears both readiness timers', async () => {
     vi.useFakeTimers()
     const source = new FakeGenerationSource()
-    source.suppressReady = true
+    source.holdReady = true
     let connected = 0
     const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
     const controller = new ConnectionController(
       source.source,
       { onConnected: () => { connected++ } },
-      { ...FAST, generationReadyTimeoutMs: 20 },
+      { ...FAST, generationReadyWarnMs: 20, generationReadyTimeoutMs: 100 },
     )
     controller.start()
     try {
@@ -587,6 +607,12 @@ describe('connection lifecycle', () => {
       expect(connected).toBe(0)
       expect(source.activeCount).toBe(1)
       expect(warnSpy).toHaveBeenCalledWith('[connection] generation is still not ready after 20ms')
+      source.releaseReady()
+      await vi.advanceTimersByTimeAsync(100)
+      expect(connected).toBe(1)
+      expect(source.activeCount).toBe(1)
+      expect(vi.getTimerCount()).toBe(0)
+      expect(warnSpy).toHaveBeenCalledTimes(1)
     } finally {
       controller.stop()
       warnSpy.mockRestore()
@@ -594,6 +620,91 @@ describe('connection lifecycle', () => {
     }
   })
 
+  it.each([20, 100, 200])('cancels an unready generation with warn=%i ms, waits for cleanup, and ignores late ready', async (generationReadyWarnMs) => {
+    vi.useFakeTimers()
+    const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0)
+    const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+    const cleanup = Promise.withResolvers<undefined>()
+    const signals: AbortSignal[] = []
+    const report: Array<(host: { home: string }) => void> = []
+    const connected = vi.fn()
+    const states: ConnectionState[] = []
+    const source: ConnectionGenerationSource = async (signal, ready) => {
+      signals.push(signal)
+      report.push(ready)
+      await new Promise<void>((resolve) => {
+        signal.addEventListener('abort', () => { resolve() }, { once: true })
+      })
+      await cleanup.promise
+    }
+    const controller = new ConnectionController(source, {
+      onConnected: connected,
+      onStateChange: state => states.push(state),
+    }, { ...FAST, generationReadyWarnMs, generationReadyTimeoutMs: 100 })
+    controller.start()
+    try {
+      await vi.advanceTimersByTimeAsync(100)
+      expect(signals[0]?.aborted).toBe(true)
+      expect(signals[0]?.reason).toMatchObject({ message: 'connection generation was not ready within 100ms' })
+      const warnings = generationReadyWarnMs <= 100
+        ? [[`[connection] generation is still not ready after ${String(generationReadyWarnMs)}ms`]]
+        : []
+      warnings.push(['[connection] connection generation was not ready within 100ms; cancelling generation'])
+      expect(warnSpy.mock.calls).toEqual(warnings)
+      report[0]!({ home: '/stale' })
+      await vi.advanceTimersByTimeAsync(1_000)
+      expect(signals).toHaveLength(1)
+      expect(connected).not.toHaveBeenCalled()
+      expect(warnSpy.mock.calls).toEqual(warnings)
+      cleanup.resolve(undefined)
+      await vi.advanceTimersByTimeAsync(5)
+      expect(signals).toHaveLength(2)
+      report[1]!({ home: '/fresh' })
+      await vi.advanceTimersByTimeAsync(0)
+      expect(connected).toHaveBeenCalledExactlyOnceWith({ home: '/fresh' })
+      expect(states).toEqual(['connecting', 'connected'])
+      expect(vi.getTimerCount()).toBe(0)
+    } finally {
+      controller.stop()
+      cleanup.resolve(undefined)
+      await vi.advanceTimersByTimeAsync(0)
+      randomSpy.mockRestore()
+      warnSpy.mockRestore()
+      vi.useRealTimers()
+    }
+  })
+
+  it.each(['stop', 'reconnect', 'failure'] as const)('clears a pending handshake deadline on %s', async (action) => {
+    vi.useFakeTimers()
+    const source = new FakeGenerationSource()
+    source.holdReady = true
+    const connected = vi.fn()
+    const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+    const controller = new ConnectionController(source.source, { onConnected: connected }, {
+      ...FAST, generationReadyWarnMs: 20, generationReadyTimeoutMs: 100,
+    })
+    controller.start()
+    try {
+      await vi.advanceTimersByTimeAsync(10)
+      source.holdReady = false
+      if (action === 'failure') source.fail(new Error('carrier failed'))
+      else controller[action]()
+      await vi.advanceTimersByTimeAsync(0)
+      source.releaseReady()
+      await vi.advanceTimersByTimeAsync(200)
+      expect(source.activeCount).toBe(action === 'stop' ? 0 : 1)
+      expect(connected).toHaveBeenCalledTimes(action === 'stop' ? 0 : 1)
+      expect(vi.getTimerCount()).toBe(0)
+      expect(warnSpy.mock.calls.some(([message]) => String(message).includes('still not ready'))).toBe(false)
+      expect(warnSpy.mock.calls.some(([message]) => String(message).includes('cancelling generation'))).toBe(false)
+    } finally {
+      controller.stop()
+      await vi.advanceTimersByTimeAsync(0)
+      warnSpy.mockRestore()
+      vi.useRealTimers()
+    }
+  })
+
   it('emits the disconnected, retry-attempt, and connected transitions', async () => {
     const source = new FakeGenerationSource()
     const states: ConnectionState[] = []

+ 34 - 3
packages/client/connection/tests/node-half.host.spec.ts

@@ -7,8 +7,8 @@ import { describe, expect, it } from 'vitest'
 import type { AddressInfo } from 'node:net'
 import type { IncomingMessage, ServerResponse } from 'node:http'
 import type { AttachmentStore } from '@deepseek-ai/dsh-attachment'
-import type { WebServer, WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
-import { API_PATH, RpcId, apply, inject, type ClientRequest, type HostConnectionHandle } from '../src/index.ts'
+import type { IndexInjection, WebServer, WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
+import { API_PATH, RpcId, apply, inject, type ClientRequest, type ConnectionConfig, type HostConnectionHandle } from '../src/index.ts'
 import { DEFAULT_MAX_REQUEST_BODY_BYTES } from '../src/http-bridge.ts'
 import { provideBrowserCredentials } from './browser-credentials.ts'
 
@@ -81,7 +81,8 @@ function fakeResponse(): {
   return { response, state }
 }
 
-async function mounted(config?: { trustedHosts?: string[] }): Promise<{
+async function mounted(config?: ConnectionConfig): Promise<{
+  ctx: Context
   routes: WebRoute[]
   upgrades: WebUpgradeRoute[]
   connection: HostConnectionHandle
@@ -95,6 +96,7 @@ async function mounted(config?: { trustedHosts?: string[] }): Promise<{
   const fiber = ctx.plugin({ inject: [...inject], apply }, config)
   await fiber.await()
   return {
+    ctx,
     routes,
     upgrades,
     connection: ctx.get('connection') as HostConnectionHandle,
@@ -116,6 +118,35 @@ function browserCookie(connection: HostConnectionHandle, authority: string): str
 }
 
 describe('connection node half', () => {
+  it('injects validated browser recovery timing and withdraws it on disposal', async () => {
+    const { ctx, dispose } = await mounted({ recovery: { generationReadyTimeoutMs: 25_000 } })
+    try {
+      const rows: IndexInjection[] = []
+      ctx.emit('webserver/index-inject', rows)
+      expect(rows).toEqual([{
+        kind: 'global', name: '__DSH_CONNECTION_RECOVERY__', value: {
+          backoffBaseMs: 500, backoffFactor: 2, backoffMaxMs: 10_000,
+          generationReadyWarnMs: 3_000, generationReadyTimeoutMs: 25_000,
+        },
+      }])
+      await dispose()
+      const after: IndexInjection[] = []
+      ctx.emit('webserver/index-inject', after)
+      expect(after).toEqual([])
+    } finally {
+      await dispose()
+    }
+  })
+
+  it.each([
+    { recovery: { backoffBaseMs: 0 }, error: /backoffBaseMs/ },
+    { recovery: { backoffFactor: NaN }, error: /backoffFactor.*finite/ },
+  ])('rejects invalid recovery timing before acquiring Host resources: $recovery', async ({ recovery, error }) => {
+    const ctx = new Context()
+    await expect(apply(ctx, { recovery })).rejects.toThrow(error)
+    expect(ctx.get('connection')).toBeUndefined()
+  })
+
   it('reserves enough default carrier capacity for the 200 MiB image batch', () => {
     expect(DEFAULT_MAX_REQUEST_BODY_BYTES).toBe(300 * 1024 * 1024)
     expect(DEFAULT_MAX_REQUEST_BODY_BYTES).toBeGreaterThan(Math.ceil(200 * 1024 * 1024 * 4 / 3) + 1024 * 1024)

+ 32 - 0
packages/client/connection/tests/recovery-config.client.spec.ts

@@ -0,0 +1,32 @@
+/** Recovery timing validation before a loop or Host bootstrap is published. */
+import { describe, expect, it } from 'vitest'
+import { resolveConnectionConfig } from '../src/recovery-config.ts'
+
+describe('connection recovery configuration', () => {
+  it('separates the slow-Host warning from the hard handshake deadline', () => {
+    expect(resolveConnectionConfig()).toEqual({
+      backoffBaseMs: 500,
+      backoffFactor: 2,
+      backoffMaxMs: 10_000,
+      generationReadyWarnMs: 3_000,
+      generationReadyTimeoutMs: 15_000,
+    })
+  })
+
+  it.each([
+    { backoffBaseMs: 0 },
+    { backoffFactor: 0.5 },
+    { backoffFactor: Infinity },
+    { backoffFactor: NaN },
+    { backoffMaxMs: -1 },
+    { generationReadyWarnMs: NaN },
+    { generationReadyTimeoutMs: 2_147_483_648 },
+    { generationReadyTimeoutMs: '15000' },
+  ])('rejects timing that could disable recovery or overflow a timer: %j', (config) => {
+    expect(() => resolveConnectionConfig(config)).toThrow()
+  })
+
+  it('preserves a finite fractional growth factor', () => {
+    expect(resolveConnectionConfig({ backoffFactor: 1.5 }).backoffFactor).toBe(1.5)
+  })
+})

+ 4 - 0
packages/client/connection/tsconfig.client.json

@@ -14,12 +14,16 @@
     "src/client/random-uuid.ts",
     "src/client/rpc.ts",
     "src/loopback-hostname.ts",
+    "src/recovery-config.ts",
     "src/rpc.ts"
   ],
   "references": [
     {
       "path": "../../../vendor/cordis"
     },
+    {
+      "path": "../../../vendor/schemastery"
+    },
     {
       "path": "../../attachment/attachment"
     },

+ 1 - 0
packages/client/connection/tsconfig.host.json

@@ -12,6 +12,7 @@
     "src/http-bridge.ts",
     "src/index.ts",
     "src/loopback-hostname.ts",
+    "src/recovery-config.ts",
     "src/rpc-host.ts",
     "src/rpc-schema.ts",
     "src/rpc.ts"

+ 1 - 1
packages/client/file-upload/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-file-upload",
   "description": "Agent-scoped browser file upload, streaming intake, and staged receipt service",
-  "version": "0.1.2-rc.1",
+  "version": "0.1.3-alpha.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/hmr/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-hmr",
   "description": "Dev-only hot-reload driver for script-loaded client entries: SSE rebuilt frames → invalidate/prefetch → fiber swap through the vendored Loader entry",
-  "version": "0.1.2-rc.1",
+  "version": "0.1.3-alpha.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/locale/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-locale",
   "description": "Locale plugin: Host-backed preference, extensible language catalog, browser fallback, and typed built-in dictionaries",
-  "version": "0.1.2-rc.1",
+  "version": "0.1.3-alpha.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/modules/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-modules",
   "description": "Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dsh.client scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam",
-  "version": "0.1.2-rc.1",
+  "version": "0.1.3-alpha.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/store/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-store",
   "description": "React-free observable and snapshot-store contracts with the shared Zustand/Immer engine",
-  "version": "0.1.2-rc.1",
+  "version": "0.1.3-alpha.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/ui-agent-preset/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-agent-preset",
   "description": "Agent-preset surfaces: the default for later sessions, this session's seat, and the composition editor",
-  "version": "0.1.2-rc.1",
+  "version": "0.1.3-alpha.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/ui-approval/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-approval",
   "description": "Approval composer takeover over the scoped Remote Event waterfall",
-  "version": "0.1.2-rc.1",
+  "version": "0.1.3-alpha.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/ui-attachment/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-attachment",
   "description": "Dynamic attachment presentation plugin for conversation input, message-image, and trajectory image slots",
-  "version": "0.1.2-rc.1",
+  "version": "0.1.3-alpha.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/ui-brand-official/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-brand-official",
   "description": "Official DeepSeek Harness brand occupants for the Web client's sidebar slots",
-  "version": "0.1.2-rc.1",
+  "version": "0.1.3-alpha.1",
   "publishConfig": {
     "access": "public"
   },

+ 2 - 2
packages/client/ui-chat/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-chat/README.md
-README.md: 79214bf2feb8384cecbb152aa31af162dc6dfdde
-README.zh.md: 674959e1974c74abba75f179eff50fe8488458fc
+README.md: 5a557f2a2125bddfe18631359665d6fa5e998458
+README.zh.md: 2fc241ad71e35eb54afcf4dde38c361248848d5f

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

@@ -25,7 +25,7 @@ The browser Chat target for Conversation assembly. It registers Chat event defin
 <a id="system-prompt-row"></a>
 ## System prompt row
 
-Chat shows a collapsed `System prompt` row for each non-empty initial or resumed request, explicit message-series start, or real system-field change. It does not repeat the row for same-series config-only or tool-only changes, tool steps, or retries. The row appears before that request's user messages, matching the provider envelope, and expands to the exact model-visible text with its original line breaks. A partial history window renders a non-initial header conservatively until the preceding page arrives; a header without a system prompt creates no row.
+Chat shows a collapsed `System prompt` row for a non-empty initial request, explicit message-series start, real system-field change, or non-initial request whose preceding header is outside the loaded history window. Once that predecessor is available, an unchanged resume does not repeat the row; same-series config-only or tool-only changes, tool steps, and retries also create no repetition. The row appears before that request's user messages, matching the provider envelope, and expands to the exact model-visible text with its original line breaks. A header without a system prompt creates no row.
 
 -----
 

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

@@ -25,7 +25,7 @@ Conversation 组装的浏览器 Chat target。本包注册 Chat event definition
 <a id="system-prompt-row"></a>
 ## 系统提示词行
 
-Chat 会为每个非空的初始或恢复请求、显式消息序列起点或真实 system 字段变化显示一行默认折叠的`系统提示词`。同一序列内仅配置或仅工具变化、工具步骤与重试不会重复该行。该行位于请求的用户消息之前,与提供方 envelope 顺序一致;展开后显示保留原始换行的精确模型可见文本。历史窗口不完整时,非初始 header 会保守显示,直到前一页到达;没有系统提示词的 header 不创建该行。
+Chat 会为非空的初始请求、显式消息序列起点、真实 system 字段变化,或前序 header 尚未进入已加载历史窗口的非初始请求显示一行默认折叠的`系统提示词`。前序 header 到达后,内容未变的 resume 不会重复该行;同一序列内仅配置或仅工具变化、工具步骤与重试也不会重复。该行位于请求的用户消息之前,与提供方 envelope 顺序一致;展开后显示保留原始换行的精确模型可见文本。没有系统提示词的 header 不创建该行。
 
 -----
 

+ 1 - 1
packages/client/ui-chat/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-chat",
   "description": "Chat Conversation target, node definitions, renderers, and details surface",
-  "version": "0.1.2-rc.1",
+  "version": "0.1.3-alpha.1",
   "publishConfig": {
     "access": "public"
   },

+ 12 - 3
packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts

@@ -78,7 +78,7 @@ export function requestPromptDefinition(inspect: RequestPromptInspector): Conver
           match.event.data.reason === 'initial',
         ),
         showsPrompt: previous === undefined
-          || match.event.data.reason !== 'change'
+          || match.event.data.reason === 'series'
           || match.event.data.startsSeries === true
           || change === 'system'
           || change === 'system-and-tools',
@@ -89,8 +89,17 @@ export function requestPromptDefinition(inspect: RequestPromptInspector): Conver
     update: context => context.state,
     buildViewNode: (context) => {
       const state = context.state
-      if (state === undefined || !state.showsPrompt || state.prompt.system === '') return null
-      return chatNode(context, 'system-prompt', state.anchorSeq, { text: state.prompt.system })
+      if (state === undefined) return null
+      const current = context.current.get('chat') as ChatNode | null | undefined
+      const visible = state.showsPrompt && state.prompt.system !== ''
+      if (!visible && current?.kind !== 'system-prompt') return null
+      return chatNode(
+        context,
+        'system-prompt',
+        state.anchorSeq,
+        { text: state.prompt.system },
+        { visibility: visible ? 'visible' : 'hidden' },
+      )
     },
   }
 }

+ 11 - 10
packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts

@@ -1402,7 +1402,7 @@ describe('built-in conversation node Definitions', () => {
     })
   })
 
-  it('materializes series starts and system changes but not same-series config or tool changes', () => {
+  it('materializes series starts and system changes but not unchanged resumes, config, or tool changes', () => {
     const tools = [{ name: 'read', description: 'Read', parameters: { type: 'object' } }]
     const expandedTools = [...tools, { name: 'write', description: 'Write', parameters: { type: 'object' } }]
     const value = assembler([
@@ -1458,7 +1458,6 @@ describe('built-in conversation node Definitions', () => {
     expect(prompts.map(prompt => ({ anchorSeq: prompt.anchorSeq, data: prompt.data }))).toEqual([
       { anchorSeq: 1, data: { text: '# Initial' } },
       { anchorSeq: 4, data: { text: '# Initial' } },
-      { anchorSeq: 5, data: { text: '# Initial' } },
       { anchorSeq: 6, data: { text: '# Updated' } },
     ])
 
@@ -1480,18 +1479,20 @@ describe('built-in conversation node Definitions', () => {
     windowed.prepend([
       at(5, 'request/header', {
         reason: 'initial',
-        header: { config: { provider: 'fake', model: 'fake' }, system: '# Original prompt' },
+        header: { config: { provider: 'fake', model: 'fake' }, system: '# Resumed prompt' },
       }),
     ], false)
     windowed.flush()
     const restored = snapshot(windowed)
-    const restoredPrompts = restored.order.flatMap((key) => {
-      const candidate = restored.nodes.get(key)
-      return candidate?.kind === 'system-prompt' ? [candidate] : []
-    })
-    expect(restoredPrompts.map(prompt => prompt.data)).toEqual([
-      { text: '# Original prompt' },
-      { text: '# Resumed prompt' },
+    const restoredPrompts = restored.nodes.values()
+      .filter(candidate => candidate.kind === 'system-prompt')
+    expect(restoredPrompts.map(prompt => ({
+      anchorSeq: prompt.anchorSeq,
+      visibility: prompt.visibility,
+      data: prompt.data,
+    })).sort((left, right) => left.anchorSeq - right.anchorSeq)).toEqual([
+      { anchorSeq: 5, visibility: 'visible', data: { text: '# Resumed prompt' } },
+      { anchorSeq: 10, visibility: 'hidden', data: { text: '# Resumed prompt' } },
     ])
   })
 

+ 1 - 1
packages/client/ui-commands/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-commands",
   "description": "Client command surface: global directory cache, '/' source, three command UI kinds, popupSelect registry",
-  "version": "0.1.2-rc.1",
+  "version": "0.1.3-alpha.1",
   "publishConfig": {
     "access": "public"
   },

+ 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: 9b81858adee21d3b8d8a158fe5ffa239b6d4ffe3
-README.zh.md: 40c02c469032e98191cbaa2a0203cc1f5a5a889e
+README.md: 6dc4c34c8c3ae809a95f9bd8d486df648317e23c
+README.zh.md: 85305791b155e80e692f1f77b105c3f7f42028b0

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

@@ -46,6 +46,8 @@ The resident composer survives no-Session and Session transitions. The no-Sessio
 
 Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order in `pendingSubmissions`; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint, encodes images through the browser's native `FileReader` data-URL path, and cites staged file receipts. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; queue and history observation by that `rpcId` retires the echo once. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their attachment ids through admission and Session scope disposal. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards. Selected generic files enter one FIFO background-upload queue; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Continuable subagents disable attachment intake and skip local echoes because their transport does not preserve the browser request id.
 
+Queued submission echoes show “Sending…” beside disabled edit, remove, and steer buttons; a collapsed dock keeps the sending status in its header. A matching Host queue row replaces the echo and enables each action according to its normal text-content and running-state requirements. Prompt acknowledgement alone does not enable queue actions. A failed submission removes its echo and displays an error; the composer restores the failed draft when it is empty or still contains the previous automatic restoration, preserving subsequently typed text.
+
 While a normal composer is running, its primary pointer action remains Stop when the draft is empty or input is unavailable. Actionable text or attachments switch the same seat to Queue Send; clearing or successfully submitting the draft restores Stop. The busy-Enter setting continues to select the Queue or Steer keyboard action. Plan mode and active goals do not change attachment intake. Continuable subagents keep separate Send and Stop actions but expose no paperclip, paste, or drop intake ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.md)).
 
 <a id="temporary-composer-entries"></a>

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

@@ -46,6 +46,8 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染
 
 默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript,繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。prompt 复用提交 `requestId`;queue 或历史以同一 `rpcId` 被观察后,回显只退休一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation service 在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。continuable 子代理禁用附件入口,也不创建本地回显,因为其 transport 不保留浏览器 request id。
 
+排队提交的本地回显在禁用的编辑、删除、插话按钮旁显示“发送中…”;折叠后的队列在标题栏保留发送状态。匹配的 Host 队列行替换回显后,各操作按原有的纯文本内容和运行状态要求启用。仅收到 prompt 确认不会启用队列操作。提交失败会移除回显并显示错误;输入框为空或仍保留上一次自动恢复的内容时,composer 恢复失败草稿,保留用户随后输入的文字。
+
 普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Queue Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置继续选择 Queue 或 Steer 键盘操作。Plan Mode 与 active goal 不改变附件入口。continuable 子代理保留独立的 Send 与 Stop 操作,但不提供回形针、粘贴或拖放入口([决策](../../../.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.zh.md))。
 
 <a id="temporary-composer-entries"></a>

+ 1 - 1
packages/client/ui-conversation/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-conversation",
   "description": "Target-neutral Conversation assembly, shell, composer, queue, and view navigation",
-  "version": "0.1.2-rc.1",
+  "version": "0.1.3-alpha.1",
   "publishConfig": {
     "access": "public"
   },

+ 2 - 0
packages/client/ui-conversation/src/client/locales.ts

@@ -142,6 +142,7 @@ export const zh = {
   'web.contentTruncated': '内容已截断',
   'details.running': '运行中…',
   'queue.count': '{n} 条排队消息',
+  'queue.sending': '发送中…',
   'queue.image': '排队消息图片',
   'queue.file': '排队文件 {name}',
   'queue.edit': '编辑排队消息',
@@ -305,6 +306,7 @@ export const en = {
   'web.contentTruncated': 'Content truncated',
   'details.running': 'Running…',
   'queue.count': '{n} queued messages',
+  'queue.sending': 'Sending…',
   'queue.image': 'Queued message image',
   'queue.file': 'Queued file {name}',
   'queue.edit': 'Edit queued message',

+ 11 - 0
packages/client/ui-conversation/src/client/queue/QueueDock.module.css

@@ -131,6 +131,10 @@
   overflow: hidden;
 }
 
+.pendingRow .attachments {
+  flex-shrink: 1;
+}
+
 .file {
   display: inline-flex;
   flex: 0 1 180px;
@@ -216,6 +220,13 @@
   gap: 10px;
 }
 
+.status {
+  flex: none;
+  color: var(--dsw-alias-label-tertiary);
+  font: var(--dsw-font-xs-13);
+  white-space: nowrap;
+}
+
 .action {
   display: grid;
   flex: none;

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

@@ -84,7 +84,8 @@ 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.
+ * collapsible count header; an empty queue renders nothing. Local submissions
+ * show sending status and disabled actions until their Host queue rows arrive.
  */
 export function QueueDock({ useSession, updateQueue, notify, loadImage, t }: QueueDockProps) {
   const inbox = useSession(s => s.queue)
@@ -155,6 +156,9 @@ export function QueueDock({ useSession, updateQueue, notify, loadImage, t }: Que
           >
             <span className={css.lead} aria-hidden><IconQueueOutline14 /></span>
             <span className={css.count}>{t('queue.count', { n: rowCount })}</span>
+            {!listVisible && pendingQueue.length > 0 && (
+              <span className={css.status} role="status">{t('queue.sending')}</span>
+            )}
             <span className={css.chevron} aria-hidden>
               {expanded ? <IconChevronDownOutline14 /> : <IconChevronUpOutline14 />}
             </span>
@@ -301,7 +305,7 @@ export function QueueDock({ useSession, updateQueue, notify, loadImage, t }: Que
           })}
           {listVisible && pendingQueue.map((submission) => {
             return (
-              <li key={submission.requestId} className={css.row} data-submission-echo="">
+              <li key={submission.requestId} className={`${css.row} ${css.pendingRow}`} data-submission-echo="">
                 {rowCount === 1 && <span className={css.lead} aria-hidden><IconQueueOutline14 /></span>}
                 {submission.attachments.length > 0 && (
                   <span className={css.attachments}>
@@ -324,6 +328,36 @@ export function QueueDock({ useSession, updateQueue, notify, loadImage, t }: Que
                   </span>
                 )}
                 <span className={css.preview}>{projectUserText(submission.text, [])}</span>
+                <span className={css.status} role="status">{t('queue.sending')}</span>
+                {queueMutable && <div className={css.actions}>
+                  <button
+                    type="button"
+                    className={css.action}
+                    aria-label={t('queue.edit')}
+                    title={t('queue.sending')}
+                    disabled
+                  >
+                    <IconEditOutline16 size={14} />
+                  </button>
+                  <button
+                    type="button"
+                    className={css.action}
+                    aria-label={t('queue.remove')}
+                    title={t('queue.sending')}
+                    disabled
+                  >
+                    <IconTrashOutline16 size={14} />
+                  </button>
+                  <button
+                    type="button"
+                    className={css.action}
+                    aria-label={t('queue.steer')}
+                    title={t('queue.sending')}
+                    disabled
+                  >
+                    <IconSendOutline14 />
+                  </button>
+                </div>}
               </li>
             )
           })}

+ 36 - 1
packages/client/ui-conversation/tests/queue-dock.client.spec.tsx

@@ -141,10 +141,19 @@ describe('QueueDock', () => {
       }],
     }
     const source = liveSession(pending)
-    const view = render(<QueueDock {...kitFor(pending)} useSession={source.useSession} />)
+    const props = kitFor(pending)
+    const view = render(<QueueDock {...props} useSession={source.useSession} />)
     expect(view.getByText('等待上传').closest('[data-submission-echo]')).not.toBeNull()
     expect(view.getByRole('img', { name: '排队消息图片' }).getAttribute('src')).toBe('blob:queue-preview')
     expect(view.getByLabelText('排队文件 notes.txt').textContent).toContain('2.4GB')
+    expect(view.getByRole('status').textContent).toBe('发送中…')
+    for (const name of ['编辑排队消息', '删除排队消息', '插话发送']) {
+      const button = view.getByRole('button', { name }) as HTMLButtonElement
+      expect(button.disabled).toBe(true)
+      fireEvent.click(button)
+    }
+    expect(props.updateQueue).not.toHaveBeenCalled()
+    expect(view.queryByRole('textbox')).toBeNull()
 
     act(() => {
       source.push({
@@ -154,6 +163,32 @@ describe('QueueDock', () => {
     })
     expect(view.getAllByText('等待上传')).toHaveLength(1)
     expect(view.container.querySelector('[data-submission-echo]')).toBeNull()
+    expect(view.queryByRole('status')).toBeNull()
+    for (const name of ['编辑排队消息', '删除排队消息', '插话发送']) {
+      expect((view.getByRole('button', { name }) as HTMLButtonElement).disabled).toBe(false)
+    }
+    fireEvent.click(view.getByRole('button', { name: '编辑排队消息' }))
+    expect((view.getByRole('textbox') as HTMLInputElement).value).toBe('等待上传')
+  })
+
+  it('keeps sending status visible while a queue containing local submissions is collapsed', () => {
+    const pending: SessionSnapshot = {
+      ...snapshotWith([row('accepted', '已排队')]),
+      pendingSubmissions: [{
+        requestId: 'req-waiting' as never, placement: 'queued', time: 1,
+        text: '等待发送', attachments: [],
+      }],
+    }
+    const source = liveSession(pending)
+    const view = render(<QueueDock {...kitFor(pending)} useSession={source.useSession} />)
+    expect(view.getByRole('status').textContent).toBe('发送中…')
+    const header = view.getByRole('button', { name: /2 条排队消息\s*发送中…/ })
+    expect(header.getAttribute('aria-expanded')).toBe('false')
+    fireEvent.click(header)
+    expect(view.getAllByRole('status')).toHaveLength(1)
+    expect(view.getByRole('status').closest('[data-submission-echo]')).not.toBeNull()
+    act(() => { source.push(snapshotWith([row('accepted', '已排队')])) })
+    expect(view.queryByRole('status')).toBeNull()
   })
 
   it('leaves pending steering to the conversation flow', () => {

+ 1 - 1
packages/client/ui-deliverables/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-deliverables",
   "description": "Produced-files turn tail and clickable final-response file references for Web",
-  "version": "0.1.2-rc.1",
+  "version": "0.1.3-alpha.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/ui-directory-picker-browse/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-directory-picker-browse",
   "description": "In-app directory browsing surface: the workspace directory-flow owner rendering the host's listing and creation primitives",
-  "version": "0.1.2-rc.1",
+  "version": "0.1.3-alpha.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/ui-directory-picker-native/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-directory-picker-native",
   "description": "Native directory-picker surface: the renderless workspace directory-flow occupant driving the host's OS chooser",
-  "version": "0.1.2-rc.1",
+  "version": "0.1.3-alpha.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/ui-goal/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-goal",
   "description": "Session goal surface: GoalBar docked above the composer, read from the goal session projection",
-  "version": "0.1.2-rc.1",
+  "version": "0.1.3-alpha.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/ui-input-trigger/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-input-trigger",
   "description": "Input trigger pipeline: '/' and '@' detection, candidate menu, pick routing to registered sources",
-  "version": "0.1.2-rc.1",
+  "version": "0.1.3-alpha.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/ui-jobs/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-jobs",
   "description": "Session-header background-job list: live registry state mirrored from session/jobs frames",
-  "version": "0.1.2-rc.1",
+  "version": "0.1.3-alpha.1",
   "type": "module",
   "main": "lib/index.js",
   "types": "lib/types/index.d.ts",

+ 1 - 1
packages/client/ui-layout/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-layout",
   "description": "Shell plugin: three-column AppFrame with drag handles, ctx.layout viewing-state service (navigation + panels)",
-  "version": "0.1.2-rc.1",
+  "version": "0.1.3-alpha.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/ui-message-feedback/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-message-feedback",
   "description": "Per-message feedback controls contributed to the assistant-message action strip, backed by the messageFeedback Host Remote",
-  "version": "0.1.2-rc.1",
+  "version": "0.1.3-alpha.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/ui-model-selection/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-model-selection",
   "description": "Model selection over the shared model catalog, Session projection, and session.selectModel",
-  "version": "0.1.2-rc.1",
+  "version": "0.1.3-alpha.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/ui-permission-presets/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-permission-presets",
   "description": "Permission surfaces: a new-session default in General settings and a current-session /permission popup over the permissions projection",
-  "version": "0.1.2-rc.1",
+  "version": "0.1.3-alpha.1",
   "publishConfig": {
     "access": "public"
   },

Some files were not shown because too many files changed in this diff