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

Merge remote-tracking branch 'origin/master' into codex/pr-3440-resync-20260908

# Conflicts:
#	docs/subsystems/llm-streaming.i18n.yaml
#	docs/subsystems/llm-streaming.md
#	docs/subsystems/llm-streaming.zh.md
#	packages/llm/llm-deepseek/README.i18n.yaml
#	packages/llm/llm-deepseek/README.md
#	packages/llm/llm-deepseek/README.zh.md
#	packages/llm/llm-deepseek/src/request-pricing.ts
#	packages/llm/llm-deepseek/tests/request-pricing.spec.ts
creatixchu пре 1 месец
родитељ
комит
31f731cc6f
100 измењених фајлова са 1058 додато и 204 уклоњено
  1. 2 2
      .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml
  2. 4 4
      .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md
  3. 4 5
      .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml
  5. 5 2
      .agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md
  6. 5 2
      .agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md
  7. 2 2
      .agents/notes/implemented/architecture/2026-09-05-package-manifest-types.i18n.yaml
  8. 4 2
      .agents/notes/implemented/architecture/2026-09-05-package-manifest-types.md
  9. 4 2
      .agents/notes/implemented/architecture/2026-09-05-package-manifest-types.zh.md
  10. 6 0
      .agents/notes/implemented/architecture/2026-09-10-command-identities-and-composer-file-action.i18n.yaml
  11. 33 0
      .agents/notes/implemented/architecture/2026-09-10-command-identities-and-composer-file-action.md
  12. 33 0
      .agents/notes/implemented/architecture/2026-09-10-command-identities-and-composer-file-action.zh.md
  13. 6 0
      .agents/notes/implemented/architecture/2026-09-10-public-package-manifest.i18n.yaml
  14. 35 0
      .agents/notes/implemented/architecture/2026-09-10-public-package-manifest.md
  15. 35 0
      .agents/notes/implemented/architecture/2026-09-10-public-package-manifest.zh.md
  16. 6 0
      .agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.i18n.yaml
  17. 27 0
      .agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.md
  18. 27 0
      .agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.zh.md
  19. 6 0
      .agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.i18n.yaml
  20. 43 0
      .agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.md
  21. 43 0
      .agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.zh.md
  22. 6 0
      .agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.i18n.yaml
  23. 29 0
      .agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.md
  24. 29 0
      .agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.zh.md
  25. 6 0
      .agents/notes/implemented/feature/2026-09-10-composer-reference-previews.i18n.yaml
  26. 31 0
      .agents/notes/implemented/feature/2026-09-10-composer-reference-previews.md
  27. 31 0
      .agents/notes/implemented/feature/2026-09-10-composer-reference-previews.zh.md
  28. 2 2
      .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml
  29. 1 1
      .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md
  30. 1 1
      .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md
  31. 2 2
      .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml
  32. 5 5
      .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md
  33. 5 5
      .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md
  34. 2 2
      .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml
  35. 1 1
      .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md
  36. 1 1
      .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md
  37. 2 2
      .agents/notes/implemented/process/2026-09-06-node-compatibility-selfhosted.i18n.yaml
  38. 1 1
      .agents/notes/implemented/process/2026-09-06-node-compatibility-selfhosted.md
  39. 1 1
      .agents/notes/implemented/process/2026-09-06-node-compatibility-selfhosted.zh.md
  40. 6 0
      .agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.i18n.yaml
  41. 26 0
      .agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md
  42. 26 0
      .agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.zh.md
  43. 6 0
      .agents/notes/implemented/process/2026-09-10-approval-review-workflow-identity.i18n.yaml
  44. 25 0
      .agents/notes/implemented/process/2026-09-10-approval-review-workflow-identity.md
  45. 25 0
      .agents/notes/implemented/process/2026-09-10-approval-review-workflow-identity.zh.md
  46. 2 2
      .agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.i18n.yaml
  47. 4 0
      .agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.md
  48. 4 0
      .agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.zh.md
  49. 1 1
      .github/AGENTS.md
  50. 1 1
      .github/review-ownership/README.md
  51. 4 2
      .github/review-ownership/check-approval.mjs
  52. 17 1
      .github/review-ownership/check-approval.test.mjs
  53. 42 2
      .github/workflows/ci-master.yml
  54. 29 12
      .github/workflows/ci.yml
  55. 3 1
      .github/workflows/expected-filenames.yml
  56. 10 1
      .github/workflows/sandbox.yml
  57. 1 0
      apps/cli/tests/web-agent-presets.e2e.ts
  58. 7 1
      apps/web/tests/agent-preset-authoring.e2e.ts
  59. 48 6
      apps/web/tests/agent-preset-selection.e2e.ts
  60. 5 2
      apps/web/tests/expected/agent-preset-authoring/created.expected.md
  61. 5 2
      apps/web/tests/expected/agent-preset-authoring/damaged.expected.md
  62. 5 2
      apps/web/tests/expected/agent-preset-authoring/section.expected.md
  63. 2 4
      apps/web/tests/expected/clickable-links-gallery/ui.expected.md
  64. 1 3
      apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md
  65. 1 3
      apps/web/tests/expected/github-ready-review/conversation.expected.md
  66. 1 3
      apps/web/tests/expected/goal-command-presentation/ui.expected.md
  67. 1 3
      apps/web/tests/expected/markdown-cjk-strong/ui.expected.md
  68. 1 3
      apps/web/tests/expected/markdown-images/ui.expected.md
  69. 1 3
      apps/web/tests/expected/markdown-inline-code-links/ui.expected.md
  70. 1 3
      apps/web/tests/expected/math-rendering/ui.expected.md
  71. 1 3
      apps/web/tests/expected/reference-composer/order.expected.md
  72. 3 0
      apps/web/tests/expected/skill-invocation-policy/preview.expected.md
  73. 7 5
      apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md
  74. 7 5
      apps/web/tests/expected/skill-user-invoke/ui.expected.md
  75. 1 3
      apps/web/tests/expected/stats-paged-history/ui.expected.md
  76. 1 3
      apps/web/tests/expected/steer-all/mid-steer.expected.md
  77. 1 3
      apps/web/tests/expected/steer-all/settled-expanded.expected.md
  78. 1 3
      apps/web/tests/expected/steer-all/settled.expected.md
  79. 1 1
      apps/web/tests/feedback-command.e2e.ts
  80. 1 1
      apps/web/tests/goal-command-presentation.e2e.ts
  81. 68 5
      apps/web/tests/lifecycle-chrome.e2e.ts
  82. 1 1
      apps/web/tests/navigation-panes.e2e.ts
  83. 15 7
      apps/web/tests/present.e2e.ts
  84. 2 9
      apps/web/tests/produced-file-mentions.e2e.ts
  85. 1 1
      apps/web/tests/seeded-history.e2e.ts
  86. 3 0
      apps/web/tests/settings-chrome.e2e.ts
  87. 2 1
      apps/web/tests/shipped-composition.e2e.ts
  88. 1 1
      apps/web/tests/sidebar-scrollbar.e2e.ts
  89. 80 3
      apps/web/tests/skill-invocation-policy.e2e.ts
  90. 24 1
      apps/web/tests/skill-user-invoke.e2e.ts
  91. 1 3
      apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md
  92. 2 2
      docs/config-catalog.i18n.yaml
  93. 4 4
      docs/config-catalog.md
  94. 4 4
      docs/config-catalog.zh.md
  95. 2 2
      docs/event-producer-consumer.i18n.yaml
  96. 8 8
      docs/event-producer-consumer.md
  97. 7 7
      docs/event-producer-consumer.zh.md
  98. 2 2
      docs/persistence-catalog.i18n.yaml
  99. 4 4
      docs/persistence-catalog.md
  100. 4 4
      docs/persistence-catalog.zh.md

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.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-03-per-session-agent-presets.md
-2026-08-03-per-session-agent-presets.md: 14d568689a662e3e7d1fedf26c22aea6faddd56b
-2026-08-03-per-session-agent-presets.zh.md: 9b296fa7a1c2b9164d9e7a0ad676929179a64fef
+2026-08-03-per-session-agent-presets.md: 8af48979b49f08c8e3ac945f648acbb615757a98
+2026-08-03-per-session-agent-presets.zh.md: 2889487e989d093c162848c3c972d46fece3726d

+ 4 - 4
.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md

@@ -27,11 +27,11 @@ The presets the deployment ships are the directories under `packages/preset/agen
 
 Mounting is per-session by default. Measured cost for a twelve-row composition is ~3ms and ~600KB per session, so isolation is the cheaper default than any sharing scheme, and a preset authored by a user or by an agent then has the smallest possible blast radius. A preset that genuinely owns an expensive singleton opts into sharing with Cordis's own `isolate` vocabulary: a named realm label is process-global, so two subtrees naming the same label resolve one instance.
 
-Which preset an unnamed session gets is a user setting (`agent-presets.default`) layered over the composition's own `default`, which becomes the `base`. Both layers are needed: the composition value is what a deployment ships and must keep working with no settings provider at all, and the setting is what a person changes without editing a `cordis.yml` they may not own.
+The `agent-presets` user-settings namespace carries `modeSelectionEnabled` and `default`. `modeSelectionEnabled` defaults to `true`: the existing new-session picker remains present and an unnamed session resolves to the saved user `default`, or the composition's deployment `default` when none exists. The Web Settings toggle changes only that policy: disabling selection temporarily uses the deployment default, while re-enabling it restores the saved user `default`. This is a deliberate exception to the ordinary user-over-composition settings precedence established in [#1539](https://github.com/deepseek-harness/deepseek-harness/pull/1539): hiding the chooser disables the user's mode-selection policy without deleting its saved value. The Host policy governs every later session whose caller omits a preset; explicitly named presets and existing sessions remain unchanged. The composition value also keeps the package working with no settings provider, while an enabled user override changes later sessions without editing a deployment-owned `cordis.yml`.
 
 ## Consequences
 
-**The effective default is read per resolution, never snapshotted.** A cached value would need a `watch` subscription and a reload path to stay honest, and the resolved scope already re-reads a hot-reloaded document. Reading through is also what makes the boundary correct rather than merely cheap: the new value applies to the next session created, and every running session keeps the composition it was built from. That invariant is the same one the session log enforces from the other side — the header records the id a session was CREATED with and an `agent-preset/selected` event records any later blank-session switch, so a reader resolves the pair (`resolveSessionPreset`) and never the header alone: a resume rebuilds the composition its history was produced under rather than the deployment default at resume time, a cold transcript's presenters resolve in that composition's layer, and the gateway rejects an attempt to adopt a live session under a preset other than the one it currently runs. A snapshot would make the two disagree at exactly the moment the setting changes.
+**The effective default is read per resolution, never snapshotted.** A cached value would need a `watch` subscription and a reload path to stay honest, and the resolved scope already re-reads a hot-reloaded document. The Host setting itself applies when an unnamed session is resolved afterwards. An explicit Web Settings action additionally routes its accepted effective default through the existing blank-session selection path only when the captured session id is still current and blank; it never recomposes a running session or rewrites that session's history. The session log enforces the same invariant from the other side — the header records the id a session was CREATED with and an `agent-preset/selected` event records any later blank-session switch, so a reader resolves the pair (`resolveSessionPreset`) and never the header alone: a resume rebuilds the composition its history was produced under rather than the deployment default at resume time, a cold transcript's presenters resolve in that composition's layer, and the gateway rejects an attempt to adopt a live session under a preset other than the one it currently runs. A snapshot would make the two disagree at exactly the moment the setting changes.
 
 **A directly-plugged subtree is invisible to the boot audit.** It never links itself to an `Entry`, so it is absent from `ctx.loader.entries()` and `assertEntriesActivated` cannot see it. The mount audits its own rows instead, reading the tree through an `Include` subclass that publishes it.
 
@@ -61,11 +61,11 @@ Which preset an unnamed session gets is a user setting (`agent-presets.default`)
 
 **A preset's package names must resolve from the harness, not from the preset.** `EntryTree.import()` resolves a row against its own tree's `baseUrl`, which `Include` sets to the composition's directory. That is right for a relative specifier and fatal for a package name: a locally authored preset lives under the user's home, where Node's upward `node_modules` walk never reaches the installed harness, so every `@deepseek-ai/dsh-*` row fails to import and the whole preset is unmountable. The shipped presets hid this — they sit inside the install. The mount records the host composition's base before plugging the subtree and sends bare specifiers there, leaving relative paths resolving from the preset so its own files still travel with it. The real-composition test writing a preset into a temp root is what found it.
 
-**The preset id is model-visible and must be logged.** It determines the tool set and prompt, so a resumed session has to restore the same composition; recording it is a session fact, not runtime state. It rides the session header beside `cwd`, and the summary carries it so a picker shows what a session actually runs rather than the deployment's current default.
+**The preset id is model-visible and must be logged.** It determines the tool set and prompt, so a resumed session has to restore the same composition; recording it is a session fact, not runtime state. It rides the session header beside `cwd`, and the summary carries it so the client surfaces show what a session actually runs rather than the deployment's current default.
 
 **A durable header field is not durable until the provider writes it.** `agentPreset` landed on `SessionHeader` with the right rationale and the JSONL provider omitted it; the derived query index also maps header fields explicitly, so a resumed Session came back with no preset and the surfaces that name it fell silent. `summarizeCold` had the same form — it hand-built the cold list row instead of reusing the shared projection. A field declared durable needs a test that crosses a real store, not only the type that declares it.
 
-**The choice belongs to the screen where it still works.** The composer seat spent almost its whole life disabled, since the preset is fixed once a turn has run. It moved to the new-session screen beside the workspace picker, where the pick is *staged*: that screen precedes the session it applies to, and the stage lands when a session becomes current and is still blank — covering both the session a workspace connect creates and the blank one it reuses, which riding `sessions.create` would miss. It is spent on first use, matching the workspace picker beside it. What a running session runs is then a read-only label in its header: a control there would promise a switch the host refuses outright.
+**The choice belongs to the screen where it still works.** The control lives on the new-session screen beside the workspace picker, is present under the default-on Host policy, and disappears only after `modeSelectionEnabled` is disabled. Its pick is *staged*: that screen precedes the session it applies to, and the stage lands when a session becomes current and is still blank — covering both the session a workspace connect creates and the blank one it reuses, which riding `sessions.create` would miss. It is spent on first use, matching the workspace picker beside it; hiding the picker discards a stage that has not reached a session and returns the current blank session to the deployment default through the same selection path. What a running or historical session runs remains a read-only label in its header: a control there would promise a switch the host refuses outright.
 
 **A preset multiplies a cost the host was already paying: nothing disposes an agent.** Measured against the shipped compositions with `--expose-gc`, one live agent holds ~0.17 MB on `minimal` and ~1.31 MB on `standard`/`cordis`, mounting in ~38 ms and ~135 ms; the first agent of a process costs ~7 MB more as Node imports the modules, which every later mount then shares. Growth is strictly linear — 10, 30 and 50 agents give the same per-agent delta — and disposal reclaims essentially all of it (50 `standard` agents held 57.8 MB and returned it). So the object graph does not leak; the lifecycle does. `ApiSessionAgentController` discards the `AgentHandle` returned by the registry, `archiveSession` only edits the workspace registry, `AgentRegistry` has no eviction, and the sole disposal site in the host is the JSON-RPC server's own shutdown. A web host therefore retains every session it has touched, at ~1.3 MB each once presets are composed rather than ~0.2 MB before. Note that pruning the mount registry does not help here: it drops records whose fiber `uid` has cleared, and an agent that never dies never clears one.
 

+ 4 - 5
.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md

@@ -27,12 +27,11 @@ Status: implemented
 
 挂载默认按会话进行。实测一份十二行组装每会话约 3ms、约 600KB,因此隔离比任何共享方案都更划算;而由用户或 agent 写出的 preset 也因此拥有尽可能小的影响面。确实自带昂贵单例的 preset,可以用 Cordis 自身的 `isolate` 词汇显式选择共享:命名 realm 的 label 是进程级全局的,因此两棵子树只要写同一个 label 就解析到同一个实例。
 
-未指名 preset 的会话拿到哪一个,是一项用户设置(`agent-presets.default`),叠在组装自身的 `default` 之上——后者成为 `base`。两层都需要:组装里的值是部署交付的东西,在完全没有 settings 提供方时也必须照常工作;而设置是让人不必去改一份可能并不属于自己的 `cordis.yml` 就能调整的东西。
+`agent-presets` 用户设置命名空间同时携带 `modeSelectionEnabled` 与 `default`。`modeSelectionEnabled` 默认为 `true`:既有的新建会话选择器保持显示;未指名会话会解析到已保存的用户 `default`,尚未保存时则使用组装中 `default` 指定的部署默认值。Web 设置开关只改变该策略:关闭选择时临时使用部署默认值,再次开启时恢复已保存的用户 `default`。这是对 [#1539](https://github.com/deepseek-harness/deepseek-harness/pull/1539) 所确立“用户值覆盖组装值”这一普通 settings 优先级的有意例外:隐藏选择器会停用用户的模式选择策略,但不会删除其保存值。该 Host 策略适用于此后所有未显式指定 preset 的会话;显式指定及既有会话不受影响。组装值还使本包在没有 settings 提供方时照常工作;选择器开启后,用户可覆盖默认值来改变后续会话,而无需编辑部署所拥有的 `cordis.yml`。
 
 ## 后果
 
-**有效默认值在每次解析时读取,绝不保存快照。** 缓存下来就需要一个 `watch` 订阅和一条重载路径才能保持诚实,而解析后的 scope 本来就会重读热重载过的文档。读穿也不只是省事,它让边界本身是对的:新值作用于**下一个新建的会话**,每个运行中的会话保持它被构建时的那份组装。这条不变量正是 session 日志从另一侧执行的同一条——header 记录会话**创建时**的 id,此后空白期的任何切换由 `agent-preset/selected` 事件记录,因此读取方解析的是两者之和(`resolveSessionPreset`)、绝不单看 header:恢复重建的是其历史所产出的那份组装而不是恢复时的部署默认值,冷读记录的 presenter 在那份组装的层里解析,网关也会拒绝把一个活着的会话收编到它当前运行的 preset 以外的 preset 之下。快照会让两者恰好在设置改变的那一刻各说各话。
-
+**有效默认值在每次解析时读取,绝不保存快照。** 缓存下来就需要一个 `watch` 订阅和一条重载路径才能保持诚实,而解析后的 scope 本来就会重读热重载过的文档。Host 设置本身会在此后解析未指名会话时生效。Web Settings 中的明确操作还会把已接受的有效默认值送入既有的空白会话选择链路,但只在操作前捕获的会话 id 仍是当前空白会话时对齐;它绝不会重新组装运行中的会话,也不会改写该会话的历史。session 日志从另一侧执行同一条不变量——header 记录会话**创建时**的 id,此后空白期的任何切换由 `agent-preset/selected` 事件记录,因此读取方解析的是两者之和(`resolveSessionPreset`)、绝不单看 header:恢复重建的是其历史所产出的那份组装而不是恢复时的部署默认值,冷读记录的 presenter 在那份组装的层里解析,网关也会拒绝把一个活着的会话收编到它当前运行的 preset 以外的 preset 之下。快照会让两者恰好在设置改变的那一刻各说各话。
 
 **直接挂载的子树对启动审计不可见。** 它不会把自己关联到 `Entry`,因此不在 `ctx.loader.entries()` 中,`assertEntriesActivated` 也看不到它。改由挂载过程自行校验各行,通过一个会公开自身 tree 的 `Include` 子类读取。
 
@@ -62,11 +61,11 @@ Status: implemented
 
 **preset 的包名必须从 harness 解析,而非从 preset 解析。** `EntryTree.import()` 按行所属树的 `baseUrl` 解析,而 `Include` 把它设为组装文件所在的目录。这对相对标识符是对的,对包名却是致命的:本地创作的 preset 位于用户主目录之下,Node 向上查找 `node_modules` 永远够不到已安装的 harness,因此每一个 `@deepseek-ai/dsh-*` 行都会导入失败,整个 preset 无法挂载。随部署提供的 preset 掩盖了这一点——它们本就在安装目录之内。挂载在插入子树之前先记录宿主组装的基址,并把裸标识符送往那里,同时让相对路径继续从 preset 解析,使它自带的文件仍随它一同迁移。发现它的正是那个把 preset 写入临时根目录的真实组装测试。
 
-**preset id 对模型可见,必须写入日志。** 它决定工具集与提示词,因此被恢复的会话必须还原同一份组装;记录它属于会话事实,而非运行时状态。它与 `cwd` 并列写在会话头部,并由会话摘要携带,使选择器显示的是某个会话实际运行的 preset,而非部署当前的默认值。
+**preset id 对模型可见,必须写入日志。** 它决定工具集与提示词,因此被恢复的会话必须还原同一份组装;记录它属于会话事实,而非运行时状态。它与 `cwd` 并列写在会话头部,并由会话摘要携带,使客户端界面显示某个会话实际运行的 preset,而非部署当前的默认值。
 
 **持久化 header 字段在 provider 写入前都算不上持久。** `agentPreset` 带着正确理由落在 `SessionHeader` 上,而 JSONL provider 遗漏了它;派生 query index 也显式映射 header 字段,于是恢复后的 Session 没有 preset,所有据以命名它的 surface 随之失声。`summarizeCold` 是同一种形式——它手工拼装 cold list row,而没有复用共享 projection。声明为持久的字段,需要一个跨越真实 store 的测试,而不只是声明它的类型。
 
-**这个选择属于它仍然可用的那个界面。** composer 座位几乎一生都处于禁用状态,因为一旦跑过一个轮次,preset 即固定。它移到了新建会话界面、工作区选择器旁边,选择在那里是**暂存**的:该界面先于它要应用到的会话存在,暂存值在某个会话成为当前会话且仍为空白时落地——这既覆盖工作区连接新建的会话,也覆盖它复用的那个空白会话,而搭 `sessions.create` 的便车会漏掉后者。它一经使用即被清空,与旁边的工作区选择器一致。至于运行中的会话在跑什么,则是其标题旁的一个只读标签:在那里放控件,等于承诺一次宿主会断然拒绝的切换。
+**这个选择属于它仍然可用的那个界面。** 控件位于新建会话界面、工作区选择器旁边,在 Host 默认开启策略下直接显示,仅在 `modeSelectionEnabled` 关闭后隐藏。选择在那里是**暂存**的:该界面先于它要应用到的会话存在,暂存值在某个会话成为当前会话且仍为空白时落地——这既覆盖工作区连接新建的会话,也覆盖它复用的那个空白会话,而搭 `sessions.create` 的便车会漏掉后者。它一经使用即被清空,与旁边的工作区选择器一致;隐藏选择器会丢弃尚未到达会话的暂存选择,并通过同一条选择链路把当前空白会话带回部署默认值。至于运行中或历史会话在跑什么,仍由其标题旁的只读标签展示:在那里放控件,等于承诺一次宿主会断然拒绝的切换。
 
 **preset 放大的是宿主本来就在付的代价:没有任何东西会 dispose 一个 agent。** 用 `--expose-gc` 对随附组装实测:一个存活的 agent 在 `minimal` 上约占 0.17 MB、在 `standard`/`cordis` 上约 1.31 MB,挂载耗时分别约 38 ms 与 135 ms;进程里第一个 agent 另需约 7 MB,那是 Node 首次 import 模块的一次性成本,此后每次挂载共享。增长严格线性——10、30、50 个的单个增量一致——且 dispose 后基本全额回收(50 个 `standard` 占住 57.8 MB,释放后全部归还)。所以对象图并不泄漏,缺的是生命周期。`ApiSessionAgentController` 会丢弃注册表返回的 `AgentHandle`,`archiveSession` 只改工作区注册表,`AgentRegistry` 没有驱逐机制,而宿主里唯一一处 dispose 是 JSON-RPC 服务器自身的关停。于是一个 web 宿主会留住它接触过的每一个会话,组装 preset 之后每个约 1.3 MB,而在此之前约 0.2 MB。注意:剪枝挂载注册表在这里没有用——它丢弃的是 fiber `uid` 已清空的记录,而永不死亡的 agent 永远不会清空它。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.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-28-subprocess-native-containment.md
-2026-08-28-subprocess-native-containment.md: 0c03884bba67dab6e2e38f96ce2e874ed62ba00f
-2026-08-28-subprocess-native-containment.zh.md: b7850e1c4fee06dfeb1a05c968d132de5994686f
+2026-08-28-subprocess-native-containment.md: 8e1e12a8536f56c9ccb515cec4c07c730c8b9b84
+2026-08-28-subprocess-native-containment.zh.md: 83bc20a29d937bca9530ca107712e4a7b39d8d4d

+ 5 - 2
.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md

@@ -22,7 +22,7 @@ The first eligible Linux ordinary or PTY call in one runtime deeply checks the e
 
 The parent creates one 0700 directory with a complete 0600 `launch-request.json` containing the final target cwd and environment. The private `DSH_SUBPROCESS_RUNNER` value locates that request while the runner starts from the provider cwd and a bootstrap-safe environment. `systemd-run --user --scope --quiet --collect --expand-environment=no` registers its process in the scope, then the one-shot bootstrap removes and validates the request, changes to the target cwd, restores the complete target environment, resolves a bare executable with the target PATH rules, clears `FD_CLOEXEC` on fd 0 through fd 2, and calls libc `execve()` with the original argv. The bootstrap becomes the target in place and preserves its inherited stdio; it does not remain as a supervisor.
 
-Request consumption or a manager observation of a loaded unit establishes scope ownership. Unit absence before either fact remains unresolved while the direct launcher is running. If that launcher exits while the request remains unconsumed, the direct result rejects with the startup failure while range observation records that the scope never existed and resolves the empty-range wait. The parent checks this unresolved interval every 50 milliseconds; after establishment, state queries back off exponentially to the existing 5-second systemctl bound. Each query reads both `LoadState` and `ActiveState`: loaded `inactive` or `failed`, or an established unit becoming `not-found`/`inactive` or otherwise collected away, proves the range empty. `active`, `activating`, `reloading`, and `deactivating` remain nonterminal. Unknown or malformed combinations and unreadable manager results reject `waitForExit()` instead of claiming quiescence. `terminate()` wakes a sleeping observer for an immediate recheck, and settlement cancels the losing backoff sleep. A strict sibling `startup-error.json` carries only request/bootstrap or target pre-exec failure, and the parent removes this spawn's private paths at observable lifecycle completion.
+Request consumption or a manager observation of a loaded unit establishes scope ownership. Unit absence before either fact remains unresolved while the direct launcher is running. If that launcher exits while the request remains unconsumed, the direct result rejects with startup failure unless its observed signal matches a termination requested while the launcher was running. A matching signal preserves the actual exit outcome for both ordinary and PTY launches; a recorded startup error always takes precedence. Range observation independently resolves an empty range when the launcher has exited and the unit is absent. The parent checks this unresolved interval every 50 milliseconds; after establishment, state queries back off exponentially to the existing 5-second systemctl bound. Each query reads both `LoadState` and `ActiveState`: loaded `inactive` or `failed`, or an established unit becoming `not-found`/`inactive` or otherwise collected away, proves the range empty. `active`, `activating`, `reloading`, and `deactivating` remain nonterminal. Unknown or malformed combinations and unreadable manager results reject `waitForExit()` instead of claiming quiescence. `terminate()` wakes a sleeping observer for an immediate recheck, and settlement cancels the losing backoff sleep. A strict sibling `startup-error.json` carries only request/bootstrap or target pre-exec failure, and the parent removes this spawn's private paths at observable lifecycle completion.
 
 The ordinary target result still comes from the same child process. The PTY path uses the same request and bootstrap without a resident runner, so the `node-pty` PID, process group, session leader, controlling terminal, foreground `inputWaiting`, `/dev/tty`, readiness, and direct terminal outcome retain their existing meanings while scope membership covers `setsid` and reparented descendants.
 
@@ -54,8 +54,9 @@ This note owns the current native-containment mechanism. It partially updates th
 
 ## Verification
 
-- Provider and Linux protocol suites pin synchronous NUL rejection before launch side effects, strict request/error decoding, target cwd and complete environment restoration, private-variable collision, symlink-sensitive PATH traversal with preserved argv, close-on-exec removal for inherited stdio, pre-exec error ownership, failed-deep-probe retry plus successful-deep-probe caching with per-call manager checks, the three scope-establishment states including an exited launcher with an unconsumed request, `LoadState`/`ActiveState` parsing, `reloading`, terminate wake-up with losing-delay cancellation, bounded established-scope backoff, and exactly-once PTY managed-owner cleanup.
+- Provider and Linux protocol suites pin synchronous NUL rejection before launch side effects, strict request/error decoding, target cwd and complete environment restoration, private-variable collision, symlink-sensitive PATH traversal with preserved argv, close-on-exec removal for inherited stdio, pre-exec error ownership, failed-deep-probe retry plus successful-deep-probe caching with per-call manager checks, the three scope-establishment states including requested versus unexpected exits with an unconsumed request, `LoadState`/`ActiveState` parsing, `reloading`, terminate wake-up with losing-delay cancellation, bounded established-scope backoff, and exactly-once PTY managed-owner cleanup.
 - Windows protocol and Win32 suites pin exactly two result branches, numeric-only target exits, ordinary-error start cancellation with raw parent-local reasons, the reduced `name`/`message`/`code`/`syscall`/`path` error record, the fixed `2`/`3`/`267` to `ENOENT`, `740` to `EACCES`, `5` to `EPERM`, `193` to `EFTYPE`, and remaining-code to `UNKNOWN` mapping, start delivery after runner spawn, empty-range settlement after pre-spawn failure, explicit ordinally sorted target environment blocks with `=C:` preservation and double-NUL termination, `uv_get_osfhandle()` carrier mapping and unsigned invalid-sentinel rejection, the null-device ignored-stdin carrier and piped non-ignored stdin, result-send and IPC-disconnect failures, direct-result latching before stdio settlement, active-process quiescence, and unique handle cleanup.
+- A keyless [`bash-startup-timeout`](../../../../snapshots/session/bash-startup-timeout/snapshot.yml) Session snapshot pins the model-facing timeout result. A Linux user-systemd fixture holds the launch request unconsumed at an input barrier and verifies cancellation plus range settlement.
 - Real Linux user-systemd tests run one ordinary and one `node-pty` `setsid`/reparent scenario through the production entry. They prove scope signalling and collection, bare executable lookup, escaped-descendant termination, range settlement, and unchanged PTY PID, session, controlling-terminal, foreground-input, `/dev/tty`, readiness, and startup-failure semantics.
 - Native Windows tests prove suspended creation, Job assignment before resume, inherited stdio, default descendant inheritance, direct result, termination, active-process zero, abnormal/disconnected runner cleanup, kill-on-close, and synchronous host-exit termination. Source, built, and Python packaged smokes enter the same runner core.
 - Public seam types, local and E2B providers, LSP and subagent consumers, shell fixtures, READMEs, the Cordis catalog, and the keyless subprocess API snapshot contain no ordinary PID; terminal PID remains.
@@ -76,6 +77,8 @@ This note owns the current native-containment mechanism. It partially updates th
 
 **Recover a failed native launch by replaying the command.** Rejected because an ambiguous failure may occur after target execution and replay can therefore execute the command twice.
 
+**Infer startup failure from an unconsumed request alone.** Rejected because abort, timeout, or disposal can terminate the launcher before request consumption. Matching an observed signal to an owner-issued termination preserves cancellation without hiding an unrelated bootstrap exit or recorded pre-exec failure.
+
 ## Consequences
 
 Supported Linux ordinary and PTY launches and Windows ordinary launches retain descendants through process-group escape and direct-parent exit, while direct target results remain independent from range quiescence. The cost is a per-spawn Linux manager check and scope/request or Windows runner/IPC/Job lifecycle, plus explicit failure when the selected owner cannot prove settlement.

+ 5 - 2
.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md

@@ -22,7 +22,7 @@ detached POSIX 进程组、Windows direct-parent 遍历与 PTY 后代扫描只
 
 parent 创建一个 0700 目录,其中的完整 0600 `launch-request.json` 保存最终 target cwd 与环境。私有 `DSH_SUBPROCESS_RUNNER` 值负责定位该 request,runner 则从 provider cwd 与 bootstrap-safe 环境启动。`systemd-run --user --scope --quiet --collect --expand-environment=no` 先把自身进程注册到 scope,再由 one-shot bootstrap 删除并校验 request、切换到 target cwd、恢复完整 target 环境、按 target PATH 规则解析裸可执行文件、清除 fd 0 至 fd 2 的 `FD_CLOEXEC`,并使用原始 argv 调用 libc `execve()`。bootstrap 会原地成为 target 并保留继承的 stdio,不作为常驻 supervisor。
 
-request 被消费或 manager 已观察到 loaded unit 都能建立 scope ownership。在这两项事实出现前,只要 direct launcher 仍在运行,unit absence 就保持未决。如果 launcher 退出时 request 仍未消费,direct result 会以 startup failure reject,而 range observation 会记录 scope 从未存在,并成功结算 empty-range wait。parent 每 50 毫秒检查一次这段未决区间;建立后,状态查询按指数增长间隔退避,最多达到既有的 5 秒 systemctl 上限。每次查询同时读取 `LoadState` 与 `ActiveState`:loaded `inactive` 或 `failed`,以及已经建立的 unit 变为 `not-found`/`inactive` 或被 collect 卸载,都能证明 range 为空。`active`、`activating`、`reloading` 与 `deactivating` 仍是非终态。未知或 malformed 组合以及不可读的 manager 结果会使 `waitForExit()` reject,而不是宣称完全停稳。`terminate()` 会唤醒正在休眠的 observer 立即复查,结算时会取消未胜出的退避 sleep。严格的同目录 `startup-error.json` 只承载 request/bootstrap 或 target pre-exec failure,parent 会在可观察生命周期完成时移除本次 spawn 的私有路径。
+request 被消费或 manager 已观察到 loaded unit 都能建立 scope ownership。在这两项事实出现前,只要 direct launcher 仍在运行,unit absence 就保持未决。如果 launcher 退出时 request 仍未消费,direct result 会以 startup failure reject,除非实际观察到的信号匹配 launcher 仍在运行时请求的终止信号。普通进程与 PTY 进程遇到匹配信号时都会保留实际退出结果;已记录的 startup error 始终优先。launcher 已退出且 unit 不存在时,range observation 会独立结算 empty-range wait。parent 每 50 毫秒检查一次这段未决区间;建立后,状态查询按指数增长间隔退避,最多达到既有的 5 秒 systemctl 上限。每次查询同时读取 `LoadState` 与 `ActiveState`:loaded `inactive` 或 `failed`,以及已经建立的 unit 变为 `not-found`/`inactive` 或被 collect 卸载,都能证明 range 为空。`active`、`activating`、`reloading` 与 `deactivating` 仍是非终态。未知或 malformed 组合以及不可读的 manager 结果会使 `waitForExit()` reject,而不是宣称完全停稳。`terminate()` 会唤醒正在休眠的 observer 立即复查,结算时会取消未胜出的退避 sleep。严格的同目录 `startup-error.json` 只承载 request/bootstrap 或 target pre-exec failure,parent 会在可观察生命周期完成时移除本次 spawn 的私有路径。
 
 普通 target result 仍来自同一个 child process。PTY 路径复用同一 request 与 bootstrap,但不增加常驻 runner,因此 `node-pty` PID、进程组、session leader、控制终端、前台 `inputWaiting`、`/dev/tty`、readiness 与 direct terminal outcome 保留既有含义,同时 scope membership 覆盖 `setsid` 与 reparent 后代。
 
@@ -54,8 +54,9 @@ selector 是 per-spawn locator 或 sentinel,不是凭据或持久格式。Linu
 
 ## Verification
 
-- provider 与 Linux 协议测试套件固定同步 NUL 拒绝发生在启动副作用之前、严格 request/error 解码、target cwd 与完整环境恢复、私有变量碰撞、保留 argv 且对 symlink 敏感的 PATH 遍历、为继承 stdio 清除 close-on-exec、pre-exec error ownership、失败深度 probe 重试与成功深度 probe 缓存及逐调用 manager 检查、三种 scope 建立状态(包括 launcher 退出且 request 未消费)、`LoadState`/`ActiveState` 解析、`reloading`、带未胜出 delay 取消的 terminate wake-up、建立后有上限的退避,以及 PTY managed-owner 恰好一次 cleanup。
+- provider 与 Linux 协议测试套件固定同步 NUL 拒绝发生在启动副作用之前、严格 request/error 解码、target cwd 与完整环境恢复、私有变量碰撞、保留 argv 且对 symlink 敏感的 PATH 遍历、为继承 stdio 清除 close-on-exec、pre-exec error ownership、失败深度 probe 重试与成功深度 probe 缓存及逐调用 manager 检查、三种 scope 建立状态(包括 request 未消费时的请求终止与意外退出)、`LoadState`/`ActiveState` 解析、`reloading`、带未胜出 delay 取消的 terminate wake-up、建立后有上限的退避,以及 PTY managed-owner 恰好一次 cleanup。
 - Windows 协议与 Win32 测试套件固定恰好两个 result 分支、只含数字的 target exit、使用普通 error 的 start cancellation 与 parent 原样保留的本地 reason、缩减到 `name`/`message`/`code`/`syscall`/`path` 的 error record、固定的 `2`/`3`/`267` 到 `ENOENT`、`740` 到 `EACCES`、`5` 到 `EPERM`、`193` 到 `EFTYPE` 及其余 code 到 `UNKNOWN` 的映射、runner spawn 后才发送 start、spawn 前 failure 的 empty-range settlement、按序数显式排序的 target 环境块及 `=C:` 保留和双 NUL 结尾、`uv_get_osfhandle()` carrier 映射与 unsigned invalid sentinel 拒绝、null-device ignored-stdin carrier 与非 ignore stdin pipe、result-send 与 IPC-disconnect failure、stdio settlement 前的 direct-result 锁存、active-process 完全停稳,以及唯一 handle cleanup。
+- 无需密钥的 [`bash-startup-timeout`](../../../../snapshots/session/bash-startup-timeout/snapshot.yml) Session 快照固定模型可见的超时结果。Linux user-systemd fixture 通过输入屏障保持启动请求未消费,并验证取消与 range settlement。
 - 真实 Linux user-systemd 测试会分别通过生产入口运行一条普通命令与一条 `node-pty` `setsid`/reparent 场景。它们证明 scope signalling 与 collection、裸可执行文件查找、逃逸后代终止、range settlement,以及不变的 PTY PID、session、控制终端、前台输入、`/dev/tty`、readiness 与 startup-failure 语义。
 - native Windows 测试证明 suspended creation、resume 前 Job assignment、继承 stdio、默认后代继承、direct result、termination、active-process zero、异常/disconnected runner cleanup、kill-on-close 与同步 host-exit termination。source、built 与 Python packaged 冒烟测试进入同一 runner core。
 - 公共 seam 类型、local 与 E2B provider、LSP 与 subagent 消费方、shell fixture、README、Cordis catalog 与 keyless subprocess API snapshot 都不包含普通 PID;terminal PID 保留。
@@ -76,6 +77,8 @@ selector 是 per-spawn locator 或 sentinel,不是凭据或持久格式。Linu
 
 **在 native launch 失败后重放命令。**不予采用,因为含糊 failure 可能发生在 target 已经执行之后,重放因此可能把命令执行两次。
 
+**仅从未消费的 request 推断启动失败。** 拒绝,因为 abort、timeout 或 dispose 可能在 request 消费前终止 launcher。将实际观察到的信号与 owner 请求的终止信号匹配,既能保留取消结果,也不会隐藏无关的 bootstrap 退出或已记录的 pre-exec failure。
+
 ## Consequences
 
 受支持的 Linux 普通与 PTY 启动、Windows 普通启动会在后代逃离进程组或 direct parent 退出后继续拥有它们,同时 direct target result 与 range 完全停稳保持独立。代价是每次 spawn 都需要一次 Linux manager 检查与 scope/request,或一套 Windows runner/IPC/Job 生命周期,而且所选 owner 无法证明 settlement 时会显式失败。

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.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-09-05-package-manifest-types.md
-2026-09-05-package-manifest-types.md: 94317a9317ba12059e726612840e4a001be2a892
-2026-09-05-package-manifest-types.zh.md: 2c40facd2591921123b5eae71b80c516f3b1f697
+2026-09-05-package-manifest-types.md: dc018ba019028b942a17cd016c8670f2405c3bc7
+2026-09-05-package-manifest-types.zh.md: 8fd323bfe6058a6a062e3834a8630c0181a203fc

+ 4 - 2
.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.md

@@ -10,11 +10,11 @@ External packages need Harness manifest types without depending on boot or clien
 
 ## Decision
 
-[`@deepseek-ai/dsh-package-manifest`](../../../../packages/util/package-manifest/README.md) owns `DshManifest` and its member declarations in one type-only file. The package belongs to the existing utility group and exports no runtime values. Author declarations and launcher-generated module fallback metadata are explicitly distinguished.
+[`@deepseek-ai/dsh-package-manifest`](../../../../packages/util/package-manifest/README.md) owns `DshManifest` and its member declarations in one type-only file. The package belongs to the existing utility group and exports no runtime values. The [public package metadata decision](2026-09-10-public-package-manifest.md) owns the public field set and the separation from internal tool metadata.
 
 Readers import the shared declarations directly. Boot retains profile loading, raw JSON checks, defaults, and resolved runtime data. Client modules retain their normalized boot graph. The image packer resolves declared paths into directories. The Session catalog generator derives a read-only validated entry with a resolved import path; raw inputs and discovery rules remain local.
 
-App-boot declares a production dependency because its published declarations reference the shared types. Client modules, the private packer, and root scripts use development dependencies because their published APIs do not expose these types. Every package consumer has a TypeScript project reference. External authors import from the utility package; app-boot provides no compatibility re-exports.
+App-boot declares a production dependency because its published declarations reference the shared types. Client modules use a development dependency because their published APIs do not expose these types. Internal image-packer and Session catalog declarations stay with their readers. Every package consumer has a TypeScript project reference. External authors import from the utility package; app-boot provides no compatibility re-exports.
 
 ## Alternatives considered
 
@@ -29,3 +29,5 @@ App-boot declares a production dependency because its published declarations ref
 Authors gain one public import path at the cost of a published package and explicit dependency edges. Existing app-boot manifest type imports must use the new package. The [profile composition design](2026-08-05-profile-plugin-bundles.md) continues to own runtime semantics; type extraction does not change configuration acceptance or model-visible behavior.
 
 Compiler and packaged NodeNext consumer checks cover public imports. Existing profile, client, image configuration, and Session catalog tests cover reader behavior; documentation checks cover the utility classification and generated package catalogs. Optional declaration fields still require deliberate consumer updates when added.
+
+Manifest format and host compatibility declarations have no enforcement in current installers or loaders. The type-only package supplies neither a SemVer parser nor an installation policy; its README records that limitation so an author declaration is not mistaken for a compatibility check.

+ 4 - 2
.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.zh.md

@@ -10,11 +10,11 @@ Status: implemented
 
 ## 决策
 
-[`@deepseek-ai/dsh-package-manifest`](../../../../packages/util/package-manifest/README.zh.md) 在一个纯类型文件中拥有 `DshManifest` 及其成员声明。本包属于现有工具库分组,不导出运行时值。作者声明与启动器生成的模块后备元数据有明确区分。
+[`@deepseek-ai/dsh-package-manifest`](../../../../packages/util/package-manifest/README.zh.md) 在一个纯类型文件中拥有 `DshManifest` 及其成员声明。本包属于现有工具库分组,不导出运行时值。[公共包元数据决策](2026-09-10-public-package-manifest.zh.md) 拥有公共字段范围及其与内部工具元数据的划分。
 
 各读取方直接导入共享声明。启动器保留 profile 加载、原始 JSON 检查、默认值和解析后的运行时数据。客户端模块保留归一化的启动图。镜像打包器将声明路径解析为目录。Session 目录生成器派生带有已解析导入路径的只读校验结果;原始输入和发现规则仍由本地负责。
 
-App-boot 声明生产依赖,因为其发布的声明文件引用共享类型。客户端模块、私有打包器和根脚本使用开发依赖,因为其发布 API 不暴露这些类型。每个包消费方都有 TypeScript 项目引用。外部作者从工具包导入;app-boot 不提供兼容性再导出。
+App-boot 声明生产依赖,因为其发布的声明文件引用共享类型。客户端模块使用开发依赖,因为其发布 API 不暴露这些类型。内部镜像打包器和 Session 目录声明保留在各自读取方。每个包消费方都有 TypeScript 项目引用。外部作者从工具包导入;app-boot 不提供兼容性再导出。
 
 ## 考虑过的替代方案
 
@@ -29,3 +29,5 @@ App-boot 声明生产依赖,因为其发布的声明文件引用共享类型
 作者获得统一的公共导入路径,代价是一个发布包和明确的依赖边。已有的 app-boot manifest 类型导入需要改用新包。[Profile 组合设计](2026-08-05-profile-plugin-bundles.zh.md) 继续负责运行时语义;类型提取不改变配置接受范围或模型可见行为。
 
 编译器与打包后的 NodeNext 消费方检查覆盖公共导入。已有 profile、客户端、镜像配置和 Session 目录测试覆盖读取行为;文档检查覆盖工具库分类与生成的包目录。新增可选声明字段时,仍需主动更新消费方。
+
+当前安装器和加载器不强制检查 manifest 格式或宿主兼容性声明。纯类型包既不提供 SemVer 解析器,也不提供安装策略;其 README 记录此限制,避免将作者声明误认为兼容性检查。

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-10-command-identities-and-composer-file-action.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/architecture/2026-09-10-command-identities-and-composer-file-action.md
+2026-09-10-command-identities-and-composer-file-action.md: 696c3486c5e007416da3fd5e7be1d4b41cb5f582
+2026-09-10-command-identities-and-composer-file-action.zh.md: 2ce803bad5d39a6d4f40b665fb4ef7456e987912

+ 33 - 0
.agents/notes/implemented/architecture/2026-09-10-command-identities-and-composer-file-action.md

@@ -0,0 +1,33 @@
+# Agent Note: Command identities and composer-owned File action
+
+Status: implemented
+
+English | [中文](2026-09-10-command-identities-and-composer-file-action.zh.md)
+
+## Problem
+
+Matching a command's English description to a client dictionary makes punctuation changes affect localization and inserted command tokens. A same-name override can also copy that description without implementing the first-party command. The File menu entry needs the composer's live attachment policy, including mount, lock, and submission state; a separate command-plugin check cannot determine that state.
+
+## Decision
+
+The command registry preserves an optional branded `CommandDefinitionId` as `definitionId` on the effective definition and descriptor. First-party producers choose their package name as the stable identity. Scoped shadowing selects the complete descriptor and never inherits the shadowed definition's identity. The identifier is discovery metadata, not an authorization claim, and does not enter command lifecycle events.
+
+The client command directory resolves input through its private `resolution.ts`. Exact registered names take priority; Chinese and English aliases select only the corresponding first-party definition in the effective Session catalog. Menu claims use the current locale's spelling; typed claims retain the supplied spelling; submissions use the resolved registered name. `presentation.ts` owns only sections, labels, descriptions, and icons. Resolution helpers and section constants are not exported from the plugin entrypoint.
+
+Conversation registers the File action through the injected command service and owns its localized label. The mounted input binds its file-dialog opener and one live availability query. Both menu filtering and invocation use that query, so lock, unmount, subagent, and submission state apply consistently. The binding and dispatch remain package-internal callbacks; no cross-plugin pick-files event is needed. The assembly uses a narrow structural action-registration face because command UI consumes Conversation's input types; a reverse compiler-project dependency would form a cycle. Its registration test checks against the command plugin's contribution type.
+
+This note supersedes only identity matching, input-resolution placement, and File-action ownership in the [composer menu decision](../feature/2026-09-08-composer-menu-sections-and-localized-rows.md). That note retains the menu, scrolling, claim-retention, and composition-timing decisions.
+
+## Alternatives considered
+
+**Match names and English descriptions.** Copy is editable and can be duplicated by unrelated definitions. A stable identity separates presentation selection from copy without moving localized text onto the Host.
+
+**Match names alone.** An agent-scoped override may deliberately provide a different command under the same name. It must not inherit first-party presentation or aliases unless it explicitly carries that identity.
+
+**Keep the File action in the command plugin.** The input owns attachment acceptance and the DOM lifetime. Maintaining a second availability condition splits one policy between owners.
+
+**Export helpers for reuse.** No production consumer needs the section constants or resolution helper as a plugin API. Tests import internal modules directly.
+
+## Consequences
+
+First-party producers and client identity mappings must agree on stable identifiers; third-party definitions may omit them. Display copy can evolve independently. Registry tests cover descriptor preservation and shadowing; client tests cover description edits in both locales, alias resolution, and exact-name priority. Composer tests cover live availability, opener replacement, unmount, and action-registration disposal. The existing Session-driven menu and command scenarios continue to own assembled browser output.

+ 33 - 0
.agents/notes/implemented/architecture/2026-09-10-command-identities-and-composer-file-action.zh.md

@@ -0,0 +1,33 @@
+# Agent Note: 命令标识与输入框拥有的文件动作
+
+Status: implemented
+
+[English](2026-09-10-command-identities-and-composer-file-action.md) | 中文
+
+## 问题
+
+用命令的英文说明匹配客户端词典,会让标点修改影响本地化和填入的命令 token。同名覆盖也可能复制这段说明,却没有实现内置命令的行为。「文件」菜单项需要输入框实时的附件策略,包括挂载、锁定和提交状态,命令插件中的另一套检查无法确定这些状态。
+
+## 决策
+
+命令注册表在有效定义和描述符上保留可选的品牌类型 `CommandDefinitionId` 字段 `definitionId`。内置命令的提供方用包名作为稳定标识。作用域覆盖选择完整描述符,不继承被遮蔽定义的标识。该标识是发现元数据,不代表授权,也不进入命令生命周期事件。
+
+客户端命令目录通过内部的 `resolution.ts` 解析输入。精确注册名优先,中英文别名只选择会话有效目录中对应的内置定义。菜单认领使用当前语言的写法,手输认领保留原写法,提交使用解析到的注册名。`presentation.ts` 只负责分节、标题、说明和图标。解析辅助函数与小节常量不从插件入口导出。
+
+Conversation 通过注入的命令服务注册「文件」动作,并负责其本地化标题。已挂载输入框绑定文件选择器回调和一个实时可用性查询。菜单过滤与实际调用使用同一个查询,统一处理锁定、卸载、subagent 和提交状态。绑定与调用留在包内回调中,不需要跨插件的文件选择事件。组装层使用窄的结构化动作注册接口,因为命令 UI 已消费 Conversation 的输入类型,反向增加编译项目依赖会形成循环。注册测试使用命令插件的贡献项类型进行校验。
+
+本记录只接管[输入框菜单决策](../feature/2026-09-08-composer-menu-sections-and-localized-rows.zh.md)中的标识匹配、输入解析归属和文件动作归属。原记录继续负责菜单、滚动、认领保留和组合输入时序的决策。
+
+## 考虑过的替代方案
+
+**按名称和英文说明匹配。** 文案可以修改,也可以被无关定义复制。稳定标识将展示选择与文案分开,无需把本地化文本移到宿主。
+
+**只按名称匹配。** agent(智能体)作用域覆盖可能刻意在同名下提供另一种命令。除非它显式携带对应标识,否则不应继承内置展示或别名。
+
+**把文件动作留在命令插件。** 输入框负责附件接收和 DOM 生命周期。维护另一套可用性条件会把同一策略分给两个模块。
+
+**导出辅助函数以供复用。** 没有生产调用方需要把小节常量或解析辅助函数作为插件 API。测试直接导入内部模块。
+
+## 后果
+
+内置命令提供方与客户端映射必须使用一致的稳定标识,第三方定义可以省略标识。显示文案可以独立调整。注册表测试覆盖描述符保留和作用域覆盖,客户端测试覆盖两种语言下修改说明、别名解析和精确名称优先。输入框测试覆盖实时可用性、回调替换、卸载和动作注册的 dispose(资源释放)。现有会话驱动的菜单和命令场景继续负责组装后的浏览器输出。

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.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/architecture/2026-09-10-public-package-manifest.md
+2026-09-10-public-package-manifest.md: 0e6c1650a6b04e98aeba1aa6ef45df78e3f2cb44
+2026-09-10-public-package-manifest.zh.md: 9b7d15f479ca1aab7bea655b2dee43625728a442

+ 35 - 0
.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.md

@@ -0,0 +1,35 @@
+# Agent Note: Public package manifest fields
+
+Status: implemented
+
+English | [中文](2026-09-10-public-package-manifest.zh.md)
+
+## Problem
+
+Plugin authors need npm identity, runtime requirements, and DSH declarations from one public import. Internal image-packaging, Session catalog, and generated proxy metadata do not define extension points for community plugins. Exposing those fields together makes internal mechanisms appear available to external authors.
+
+## Decision
+
+[`DshPackageManifest`](../../../../packages/util/package-manifest/src/types.ts) describes the package.json fields DSH uses, with required `name` and `version`. Its optional `dsh` member uses `DshManifest` for public composition and author metadata. The type is a selected npm field set, not a complete package.json schema. App-boot adapts it with `Partial` for local profiles, which need no published identity.
+
+Runtime requirements live at top-level `engines`: `dsh`, `node`, and `npm` are optional version strings, and other engine names are allowed. `dsh.manifestVersion` identifies declaration format `1`. Format and DSH compatibility declarations are not enforced by current installers or loaders.
+
+The image packer owns `configTrees`, the workspace catalog generator owns Session migration declarations, and app-boot owns generated module-fallback metadata. Their existing on-disk keys remain readable by those internal tools, but the public manifest types do not expose them. This scope refines the [shared declaration ownership decision](2026-09-05-package-manifest-types.md), whose package placement and dependency rules remain active.
+
+Each consumer owns JSON parsing, field validation, default resolution, and adaptation to runtime data. Interfaces do not validate parsed JSON. A helper belongs in the shared package only when multiple consumers need the same validation or normalization; getters that repeat property access add no shared policy.
+
+## Alternatives considered
+
+**Keep internal metadata in the public declaration.** A workspace-only migration catalog and an experimental image packer cannot offer public plugin behavior merely because their metadata is discoverable.
+
+**Put DSH compatibility under `dsh.engines`.** [VS Code](https://code.visualstudio.com/api/references/extension-manifest) places its host requirement in top-level `engines.vscode`. Top-level `engines.dsh` gives authors one location for runtime requirements; DSH still owns enforcement of its custom key.
+
+**Use peer dependencies as the sole host requirement.** Peer dependencies constrain installed npm packages, including the CLI package `@deepseek-ai/dsh`. They do not identify the currently running DSH process when plugins live in a separate profile project.
+
+**Parse every domain through one mandatory parser.** Existing readers consume different subsets and own different errors and defaults. Combining them would make a client reader validate unrelated profile declarations. The public types remain independent of filesystem access and parsing policy.
+
+## Consequences
+
+External authors gain a complete package-level declaration and a smaller DSH author API. Consumers of removed internal types must use their owning implementations. The packer and repository catalog no longer depend on the public declaration package; app-boot retains a production dependency because its published profile type references it.
+
+Compiler and built NodeNext import checks verify required package identity, partial profiles, top-level engine declarations, and the absence of internal fields from the public API. Existing profile, packer, and Session catalog tests retain coverage of their accepted files and malformed declarations. No Session format, plugin loading rule, or model-visible behavior changes.

+ 35 - 0
.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.zh.md

@@ -0,0 +1,35 @@
+# Agent Note: 公共 package manifest 字段
+
+Status: implemented
+
+[English](2026-09-10-public-package-manifest.md) | 中文
+
+## 问题
+
+插件作者需要从统一的公共导入路径获取 npm 身份、运行时要求和 DSH 声明。内部镜像打包、Session 目录和生成的代理元数据不定义社区插件扩展点。将这些字段一起暴露,会让外部作者误以为内部机制也可供使用。
+
+## 决策
+
+[`DshPackageManifest`](../../../../packages/util/package-manifest/src/types.ts) 描述 DSH 使用的 package.json 字段,其中 `name` 和 `version` 必填。其可选的 `dsh` 成员使用 `DshManifest` 描述公共组合与作者元数据。该类型只选取所需 npm 字段,不是完整的 package.json schema(模式)。App-boot 通过 `Partial` 适配无需发布身份的本地 profile。
+
+运行时要求位于顶层 `engines`:`dsh`、`node` 和 `npm` 均为可选版本字符串,也允许其他 engine 名称。`dsh.manifestVersion` 标识声明格式 `1`。当前安装器和加载器不强制检查格式与 DSH 兼容性声明。
+
+镜像打包器拥有 `configTrees`,工作区目录生成器拥有 Session 迁移声明,app-boot 拥有生成的模块后备元数据。这些内部工具仍可读取既有磁盘字段,但公共 manifest 类型不暴露这些字段。此范围细化了[共享声明归属决策](2026-09-05-package-manifest-types.zh.md),后者的包位置与依赖规则仍然有效。
+
+各消费方负责 JSON 解析、字段校验、默认值解析和运行时数据适配。接口不会校验已解析的 JSON。只有多个消费方需要相同校验或归一化时,helper 才属于共享包;重复属性访问的 getter 不提供共享策略。
+
+## 考虑过的替代方案
+
+**将内部元数据保留在公共声明中。** 仅限工作区的迁移目录和实验性镜像打包器,不会因为其元数据可被发现就提供公共插件行为。
+
+**将 DSH 兼容性放在 `dsh.engines` 下。** [VS Code](https://code.visualstudio.com/api/references/extension-manifest) 将宿主要求放在顶层 `engines.vscode`。顶层 `engines.dsh` 让作者在同一位置声明运行时要求;自定义键的检查仍由 DSH 负责。
+
+**仅用 peer dependency 声明宿主要求。** Peer dependency 约束已安装的 npm 包,包括 CLI 包 `@deepseek-ai/dsh`。插件位于独立 profile 项目时,它们无法标识当前运行的 DSH 进程。
+
+**通过统一的强制解析器解析所有领域。** 现有读取方消费不同字段子集,并各自拥有错误与默认值。合并它们会让客户端读取方校验无关的 profile 声明。公共类型保持独立于文件系统访问和解析策略。
+
+## 后果
+
+外部作者获得完整的包级声明和更小的 DSH 作者 API。已移除内部类型的消费方必须使用各自负责的实现。打包器与仓库目录不再依赖公共声明包;app-boot 保留生产依赖,因为其发布的 profile 类型引用该包。
+
+编译器和构建后的 NodeNext 导入检查验证包身份必填、部分 profile、顶层 engine 声明,以及公共 API 不含内部字段。现有 profile、打包器和 Session 目录测试继续覆盖其接受的文件与畸形声明。Session 格式、插件加载规则和模型可见行为均不改变。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.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-10-deepseek-image-token-calculator-v41.md
+2026-09-10-deepseek-image-token-calculator-v41.md: d8042b04c1ed7824720485aa3ccf584f913d0726
+2026-09-10-deepseek-image-token-calculator-v41.zh.md: 6be386da1c66c469329d03e7b86c8c0e2c22ce3a

+ 27 - 0
.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.md

@@ -0,0 +1,27 @@
+# Agent Note: DeepSeek image-token estimator on the v41 calculator
+
+Status: implemented
+
+English | [中文](2026-09-10-deepseek-image-token-calculator-v41.zh.md)
+
+## Problem
+
+`deepSeekImageTokens()` in `llm-deepseek` ported the provider's published image-token calculator in its `v4` configuration: a 384×384 scale-up floor, a 384-token cap, an 8:1 width clamp, a grid layout that adds a row for odd row counts and parity corrections, and a pad-to-4 alignment charged at its worst case. The provider's Vision guide now documents a different projection for the current Flash model: images below roughly 544×544 total pixels scale up, larger images scale down to roughly 1300×1300 total pixels, and one image costs at most 1024 tokens. The published calculator carries this as a `v41` configuration and the docs page instantiates that one. The old port underprices an 800×800 request image by 73 tokens, which can delay automatic compaction in sessions containing these images. The error depends on dimensions: a 640×480 image is overestimated by 3 tokens.
+
+## Decision
+
+`image-tokens.ts` is rewritten as a verbatim port of the `v41` configuration. The constants are a 14px patch, 3:1 per-axis downsampling, a 544×544 total-pixel floor, and a 1024-token cap. The grid formula is `rows × (cols + 1) + 2` with no odd-row extra row, no parity correction, and no even-row trimming in the solver. There is no alignment pad, so the estimate is exact rather than a worst-case upper bound, and there is no aspect-ratio clamp, so extreme aspect ratios reach the cap through the solver's one-row and one-column branches. The over-budget path is a single closed-form solve followed by the published assertion; the decrementing retry loop existed only for the odd-row layout. The provider's fixpoint iteration over the projected dimensions is unchanged.
+
+The test vectors are re-pinned from the published calculator. The request-pricing tests, package README, and this note carry the new numbers; the pixel budget the harness applies before pricing (`DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET`, 640,000 total pixels) and the catalog model ids are unchanged.
+
+## Alternatives considered
+
+**Keep both configurations and select by model id.** The provider states that requests to the retired `deepseek-v4-flash-vision-exp` id are served by the current Flash model, so no reachable route prices under the old configuration. Two configurations would keep dead branches and their tests alive.
+
+**Keep the generic class with the `isNLayout`, pad, and ratio-clamp switches.** A one-configuration port has fewer unreachable branches to exclude from coverage and states the shipped rule directly; a future provider revision changes this one module and its pinned vectors either way.
+
+**Raise the harness pixel budget in the same change.** The provider now accepts roughly 1300×1300 total pixels per image, so the harness's 640,000-pixel projection discards detail the model could use. That is a request-content change with its own snapshot impact, separate from pricing what is actually sent.
+
+## Consequences
+
+An 800×800 request image costs 422 tokens instead of 349, while a 640×480 image costs 206 instead of 209 and a low-budget 512×512 image costs 184 instead of 201. Compaction pressure changes with the retained image dimensions. The 640,000-pixel budget does not imply a 422-token ceiling: an 8192×1 image stays within that pixel budget and costs 1024 tokens. The estimate no longer carries a three-token conservative margin; provider usage remains the authoritative anchor once a request completes. Sessions replayed through `llm-replay` use their fixture's `imageRequestTokens` and are unaffected.

+ 27 - 0
.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.zh.md

@@ -0,0 +1,27 @@
+# Agent Note: DeepSeek 图片 token 估算器改用 v41 计算器
+
+Status: implemented
+
+[English](2026-09-10-deepseek-image-token-calculator-v41.md) | 中文
+
+## 问题
+
+`llm-deepseek` 中的 `deepSeekImageTokens()` 移植的是提供方公开图片 token 计算器的 `v4` 配置:384×384 放大下限、384 token 上限、8:1 宽度钳制、奇数行数额外加一行并做奇偶校正的网格布局,以及按最坏情况计价的 pad-to-4 对齐。提供方的图像理解指南现在为当前 Flash 模型记录了另一套投影规则:总像素小于约 544×544 的图片放大,更大的图片缩小到约 1300×1300 总像素,单张图片最多 1024 token。公开计算器以 `v41` 配置承载这套规则,文档页实例化的也是它。旧移植对一张 800×800 的请求图片低估 73 token,可能使包含这类图片的会话延迟触发自动压缩。误差取决于尺寸:一张 640×480 的图片会被高估 3 token。
+
+## 决策
+
+`image-tokens.ts` 重写为 `v41` 配置的逐句移植。常量为 14px patch、每轴 3:1 降采样、544×544 总像素下限、1024 token 上限。网格公式为 `rows × (cols + 1) + 2`,没有奇数行额外行、没有奇偶校正、求解器也不再把行数截成偶数。没有对齐 pad,所以估算值是精确值而非最坏情况上界;没有宽高比钳制,所以极端长宽比会经求解器的单行和单列分支到达上限。超预算路径是一次闭式求解加上公开的断言;逐步递减的重试循环只服务于奇数行布局。提供方对投影尺寸的定点迭代保持不变。
+
+测试向量按公开计算器重新固定。request-pricing 测试、包 README 和本 note 使用新数字;harness 在定价前应用的像素预算(`DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET`,640,000 总像素)和 catalog 模型 id 不变。
+
+## 备选方案
+
+**保留两套配置并按模型 id 选择。** 提供方说明发往已下线的 `deepseek-v4-flash-vision-exp` 的请求由当前 Flash 模型承接,所以没有可达路由会按旧配置计价。两套配置会保留死分支及其测试。
+
+**保留带 `isNLayout`、pad 和宽高比钳制开关的通用类。** 单配置移植需要从覆盖率中排除的不可达分支更少,并直接陈述已上线的规则;提供方未来再修订时,两种写法都只改这一个模块和它固定的向量。
+
+**在同一改动中提高 harness 像素预算。** 提供方现在每张图接受约 1300×1300 总像素,harness 的 640,000 像素投影会丢弃模型本可利用的细节。那是请求内容的改动,有自己的快照影响,与为实际发送内容计价是两件事。
+
+## 后果
+
+800×800 请求图片的计价从 349 变为 422 token,640×480 图片从 209 变为 206,低预算下的 512×512 图片从 201 变为 184。压缩压力随保留图片的尺寸变化。640,000 像素预算不意味着 422 token 上限:8192×1 图片在该像素预算内,仍计 1024 token。估算值不再带 3 token 的保守余量;请求完成后,提供方 usage 仍是权威锚点。经 `llm-replay` 回放的会话使用各自 fixture 的 `imageRequestTokens`,不受影响。

+ 6 - 0
.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.md
+2026-09-08-composer-menu-sections-and-localized-rows.md: 3cccb3c038ae4c358860f09533e966e9121b987e
+2026-09-08-composer-menu-sections-and-localized-rows.zh.md: 31c879b17ac6788bfade50a3245903c3e2e56224

+ 43 - 0
.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.md

@@ -0,0 +1,43 @@
+# Agent Note: Composer menu sections, localized rows, and the File row
+
+Status: implemented
+
+English | [中文](2026-09-08-composer-menu-sections-and-localized-rows.zh.md)
+
+## Problem
+
+The composer's `+` button and a typed `/` listed every command in Host registration order as `name description`, all lowercase, with no glyphs and no grouping, beside a separate paperclip button for files. Under Chinese the rows stayed English because Host descriptors carry English text only, and a user who knew a command by its Chinese title could neither find it by that title nor see what to type. Issue #3567 and the design doc for it ask for two sections in usage order, a glyph and a left-aligned title per row with the description right-aligned, capitalized English titles, Chinese titles and descriptions that stay searchable in both languages and show the English command name, a Chinese fill for the Plan and Goal claims, and a File entry inside the menu.
+
+## Decision
+
+`ui-commands` owns the menu's presentation in `src/client/presentation.ts`: an Add section (`file`, `goal`, `plan`, `feedback`) and a Commands section (`compact`, `permission`, `model`, `export`), each in usage order, with rows outside both lists closing Commands in catalog order. The section lists are keyed by name; an empty query returns the sectioned rows, and a typed query returns the flat ranking of every visible row so the best match is always first.
+
+The six built-in Host commands get their localized title, description, glyph, and claim token from the client. Stable identity selection and input resolution follow the [command identity decision](../architecture/2026-09-10-command-identities-and-composer-file-action.md). Contributions carry `label()`, `description()`, and `icon`, read on every candidate pass, so the `/model` row localizes without re-registration.
+
+A menu pick fills the locale's claim token: under Chinese, picking Plan fills `/计划 ` and the submission executes `/plan `. A typed token keeps its typed spelling as the claim, because the composer reads the arguments after the token it holds. The effective Session catalog resolves Chinese and English aliases through the same input path in every locale.
+
+The File row is an `action` contribution: a bare invocation consumes the trigger token and runs a client callback without submitting a message. Conversation owns that registration, its live availability, and the hidden file input. The menu replaces the separate paperclip button; the `+` button's accessible name and tooltip read "Add files or run commands".
+
+`ui-input-trigger` renders the new row anatomy: `InputTriggerCandidate.label` is the title and a second search key of the shared `rankByName`, the name renders as a trailing alias when the label differs from it, `icon` accepts an icon component beside the reference glyph tokens, and the description is right-aligned. `ui-primitives` gains the Plan glyph from the design doc, a static ring for Compact, and the permission shield contour.
+
+The menu uses a 400 px border-box height cap, which fits both headings and the eight built-in rows before the viewport clamp reduces it. A real overflow keeps a 10 px draggable WebKit rail around a 4 px visible thumb, insets the track from the rounded ends, and shows a bottom fade until the viewport reaches the final row; Firefox keeps its standard thin scrollbar.
+
+## Alternatives considered
+
+**Release a claim when its separator is deleted.** The complete command name still identifies the selected command. Keeping the claim until the name changes preserves its highlight through argument replacement and avoids relying on an IME-generated space to run ordinary keydown adjudication. The shared input machine applies this rule to every command token, including failure recovery; neither the command name nor the locale selects a separate implementation.
+
+**Restore placeholders directly on native composition end.** Browsers can deliver that event before Lexical reconciles the final text. The shared editor binding keeps command hints and ordinary placeholders hidden while either native or editor composition remains active, and reevaluates visibility after an editor commit, including a cancellation that changes no text. Keyboard submit guards retain their separate post-composition window.
+
+**Localize descriptions on the Host.** The Host has no locale and its catalog is shared by every client; localized product copy belongs to the client.
+
+**Keep sections under a typed query.** Ranking inside sections put a prefix hit in Commands below weaker matches in Add (typing `e` listed Feedback and File above Export); a flat ranking keeps the best match first, and the headings only carry information while the list is complete.
+
+**Keep the paperclip beside the menu.** The design doc and the issue's acceptance criteria integrate the entry into the menu and forbid the old icon from showing twice; the hidden file input and the drop gate stay where they were.
+
+**A contribution-owned copy for the built-in Host rows.** Host packages have no client half to register from, so the copy has to live on the client; one table keyed by name in the package that already owns the `/` source keeps the design decision in one place.
+
+**A `token` alias only under the active locale.** Resolving through every dictionary costs nothing and lets a draft persisted under one locale submit under another.
+
+## Consequences
+
+The menu and localized presentation remain client-owned, while the Host owns effective command definitions. Adding a localized first-party command requires its identity mapping, dictionary entries, icon, and menu position. Session-driven browser scenarios and owner-local ARIA expectations cover the menu. Unrelated same-name definitions keep their own copy and no first-party glyph. The command identity decision owns this distinction and the File action's lifecycle.

+ 43 - 0
.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.zh.md

@@ -0,0 +1,43 @@
+# Agent Note: Composer 菜单分节、本地化行与「文件」行
+
+Status: implemented
+
+[English](2026-09-08-composer-menu-sections-and-localized-rows.md) | 中文
+
+## 问题
+
+composer 的 `+` 按钮与键入的 `/` 按宿主注册顺序把每个命令列成 `name description`,全部小写,没有图标也不分组,旁边还有一个单独的回形针按钮用于添加文件。中文界面下各行仍是英文,因为宿主描述符只携带英文文案;只知道命令中文名的用户既搜不到它,也看不出该输入什么。Issue #3567 及其设计稿要求:按使用频次分成两个小节,每行带图标、标题左对齐、说明右对齐,英文标题首字母大写,中文标题与说明在两种语言下都能搜索并同时显示英文命令名,计划与目标的声明用中文填入,以及把「文件」入口放进菜单。
+
+## 决策
+
+`ui-commands` 在 `src/client/presentation.ts` 里拥有菜单的展示:「添加」小节(`file`、`goal`、`plan`、`feedback`)与「指令」小节(`compact`、`permission`、`model`、`export`),各按使用频次排列,不在两个清单里的行按目录顺序排在「指令」末尾。小节清单按名字索引;空查询返回分节的行,输入查询后返回所有可见行的平铺排序,最佳匹配始终在第一位。
+
+六个内置宿主命令从客户端取得本地化标题、说明、图标和认领 token。稳定标识选择与输入解析遵循[命令标识决策](../architecture/2026-09-10-command-identities-and-composer-file-action.zh.md)。贡献项携带 `label()`、`description()` 和 `icon`,每次生成候选项时读取,因此 `/model` 行无需重新注册即可本地化。
+
+菜单选中填入当前语言的认领 token:中文下选中「计划」填入 `/计划 `,提交时执行 `/plan `。手输 token 保留原写法作为认领,因为输入框从它持有的 token 之后读取参数。会话有效目录在所有界面语言下通过同一输入路径解析中英文别名。
+
+「文件」行是一个 `action` 贡献项:裸调用消费触发 token 后运行客户端回调,不提交消息。Conversation 负责该注册、实时可用性和隐藏文件输入框。菜单取代单独的回形针按钮,`+` 按钮的无障碍名称与提示为「添加文件或调用指令」。
+
+`ui-input-trigger` 渲染新的行结构:`InputTriggerCandidate.label` 是标题,也是共享 `rankByName` 的第二个搜索键;label 与名字不同时名字渲染为尾随别名;`icon` 在引用图标 token 之外接受图标组件;说明右对齐。`ui-primitives` 新增设计稿给出的计划图标、压缩用的静态环形,以及权限盾形轮廓。
+
+菜单采用 400 px 的 border-box 高度上限,在视口限制缩小高度前可容纳两个小节标题与八个内置入口。内容确实溢出时,WebKit 系浏览器用 10 px 的可拖动区域承载 4 px 的可见滑块,轨道避开圆角两端,视口抵达最后一行前显示底部渐隐;Firefox 保留标准细滚动条。
+
+## 考虑过的替代方案
+
+**删除分隔空格时释放命令认领。** 完整命令名仍能标识已选命令。保留认领直到命令名改变,可以在替换参数时保持高亮,也无需依赖输入法生成的空格触发普通按键裁决。共享输入状态机对所有命令 token 使用这个规则,提交失败后的恢复也相同;命令名和界面语言都不选择另一套实现。
+
+**在原生组合输入结束事件中直接恢复占位文字。** 浏览器可能先发送该事件,Lexical 随后才完成最终文字更新。共享编辑器绑定在原生输入法或编辑器仍处于组合输入时隐藏命令提示和普通占位文字,并在编辑器提交更新后重新判断显隐,包括没有文字变化的取消操作。键盘提交保护保留独立的组合输入结束后保护时段。
+
+**在宿主侧本地化说明。** 宿主没有语言设置,目录由所有客户端共享,本地化产品文案属于客户端。
+
+**输入查询后保留小节。** 在小节内排序会把「指令」里的前缀命中排在「添加」里较弱的匹配之下(输入 `e` 时反馈与文件排在导出之上);平铺排序让最佳匹配始终靠前,小节标题只在列表完整时才有信息量。
+
+**在菜单旁保留回形针。** 设计稿与 issue 的验收条件都要求把入口整合进菜单且旧图标不再重复显示;隐藏的文件输入框与拖放门槛保持原位。
+
+**由贡献项拥有内置宿主行的文案。** 宿主包没有客户端一半可供注册,文案只能放在客户端;在已经拥有 `/` source 的包里用一张按名字索引的表,能把设计决策集中在一处。
+
+**只在当前语言下解析 `token` 别名。** 遍历所有词典解析没有成本,还能让在一种语言下持久化的草稿在另一种语言下提交。
+
+## 后果
+
+菜单与本地化展示由客户端负责,宿主负责有效命令定义。新增本地化内置命令需要对应的标识映射、词典条目、图标和菜单位置。会话驱动的浏览器场景与归属模块的 ARIA 预期覆盖菜单。无关的同名定义保留自己的文案,不获得内置图标。命令标识决策负责这一划分和文件动作的生命周期。

+ 6 - 0
.agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.md
+2026-09-09-turn-duration-hour-unit.md: f0f5cb478dafa7938614bdeeec3a6de9f58aed8d
+2026-09-09-turn-duration-hour-unit.zh.md: c9c94eef2a88a0f58d5f5a05332b4b5034ef977a

+ 29 - 0
.agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.md

@@ -0,0 +1,29 @@
+# Agent Note: Turn duration labels gain an hour unit
+
+Status: implemented
+
+English | [中文](2026-09-09-turn-duration-hour-unit.zh.md)
+
+## Problem
+
+The Web chat's turn duration labels counted minutes without bound. `formatRunDuration` in [message-chrome.ts](../../../../packages/client/ui-chat/src/client/chat/message-chrome.ts) split elapsed milliseconds into seconds and minutes only, so a turn that ran for 90 minutes read `90分05秒` / `90m 05s` in all three places sharing the formatter: the `Deep diving...` running clock, the settled `Ran for {duration}` footer, and the turn-time dialog's total. The archived [turn run time decision](../../archived/feature/2026-08-03-web-turn-run-time.md) fixed the clock's anchor and the shared whole-second floor; it left the formatter at two units, which stops reading correctly once a turn crosses an hour.
+
+## Decision
+
+`formatRunDuration` carries an hour branch: elapsed time at or above 3600 seconds renders through `duration.hours` with zero-padded minutes and seconds — `1小时05分03秒` / `1h 05m 03s` — while everything below an hour keeps the existing second and minute branches unchanged. Hours appear only at or above 3600 seconds, so 3599 seconds still reads `59分59秒` and `60分00秒` never appears. Seconds are retained rather than dropped once hours appear, because the running clock ticks every second and a `1小时05分` label would sit still for a minute at a time. Negatives still clamp to zero and partial seconds still floor. `duration.hours` joins both dictionaries in [locale.ts](../../../../packages/client/ui-chat/src/client/locale.ts), and `RunDurationTranslate` widens to three keys.
+
+The change is confined to the turn formatter. `StatsPills.formatDuration` — the session-wide aggregate pill reading `45.2s` / `2m42s` — keeps its own two-unit format.
+
+## Alternatives considered
+
+**Pair hours with minutes only.** Dropping seconds at the hour boundary matches the `ui-jobs` job-duration format and keeps the label short. It loses the second-level figure from the settled footer, and it makes the live clock look frozen: the label would change once a minute while the turn is still running.
+
+**Three units while running, two once settled.** Rejected because both readings come from one function by design — the archived decision pins that — so the same turn would report different precision before and after it settles.
+
+**Change the aggregate pill in the same change.** `StatsPills.formatDuration` measures session-wide aggregates — LLM time, tool time, and average TTFT — from projections rather than one turn's boundaries. It can exceed an hour too, but folding it in would mix two independent formatters and their tests into one change.
+
+**Leave minutes unbounded.** `90分05秒` is technically correct and costs nothing to keep, but it is the reading that prompted this change and grows harder to parse the longer a turn runs.
+
+## Consequences
+
+Long turns now read in hours without new session events or new timing state; the labels remain derived from the logged `turn/start` and `turn/end` boundaries and keep the 15-second clock delay. The unit spec covers the 3599-second and 3600-second boundary plus the English template. No recorded-session snapshot changes: every shipped `Deep diving...` expectation is captured before the clock appears.

+ 29 - 0
.agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: 回合耗时标签增加小时单位
+
+Status: implemented
+
+[English](2026-09-09-turn-duration-hour-unit.md) | 中文
+
+## 问题
+
+Web 聊天的回合耗时标签分钟数无界增长。[message-chrome.ts](../../../../packages/client/ui-chat/src/client/chat/message-chrome.ts) 里的 `formatRunDuration` 只把毫秒拆成秒和分两级,于是一个跑了 90 分钟的回合在共用一个格式化器的三处都读作 `90分05秒` / `90m 05s`:`Deep diving...` 后的实时计时、收尾的 `Ran for {duration}` 页脚,以及耗时详情弹窗里的总用时。已归档的[回合运行时长决策](../../archived/feature/2026-08-03-web-turn-run-time.md)确定了计时锚点和共用的整秒向下取整;它把格式化器留在两级单位,一旦回合跨过一小时就不再正确。
+
+## 决定
+
+`formatRunDuration` 增加小时分支:总时长达到或超过 3600 秒时经 `duration.hours` 渲染,分和秒补零——`1小时05分03秒` / `1h 05m 03s`——不足一小时的时长保持原有的秒、分两级不变。小时只在达到或超过 3600 秒时出现,因此 3599 秒仍读作 `59分59秒`,不会出现 `60分00秒`。出现小时后保留秒而不丢弃,因为实时计时每秒都在走,`1小时05分` 这样的标签会整整一分钟静止不动。负值仍然钳到 0,不足一秒仍然向下取整。[locale.ts](../../../../packages/client/ui-chat/src/client/locale.ts) 的两套字典都新增 `duration.hours`,`RunDurationTranslate` 扩为三个键。
+
+改动只限于回合计时器。`StatsPills.formatDuration`——会话级聚合胶囊,读作 `45.2s` / `2m42s`——保留自己的两级格式。
+
+## 考虑过的替代方案
+
+**小时只与分配对。** 在小时边界丢掉秒与 `ui-jobs` 的任务时长格式一致,标签也更短。代价是收尾页脚失去秒级数字,并且实时计时看起来像卡住了:回合还在跑,标签却每分钟才变一次。
+
+**运行中三单位、收尾两单位。** 拒绝,因为两处读数按设计来自同一个函数——归档决策锁定了这一点——同一个回合会在结束前后报告不同的精度。
+
+**在同一次改动里改聚合胶囊。** `StatsPills.formatDuration` 统计的是来自投影的会话级聚合值——LLM 耗时、工具耗时与平均 TTFT——而不是单个回合的边界。它同样可能超过一小时,但把它折进来会让两个独立的格式化器及其测试混进同一次改动。
+
+**保留分钟无界。** `90分05秒` 在技术上没错,保留也不花成本,但它正是引发这次改动的读数,而且回合跑得越久越难解析。
+
+## 后果
+
+长回合现在按小时展示,没有新增会话事件或计时状态;标签仍由日志中的 `turn/start` 与 `turn/end` 边界派生,并保留 15 秒后才出现计时的延迟。单元 spec 覆盖 3599 秒与 3600 秒的边界以及英文模板。录制会话快照没有变化:所有已发布的 `Deep diving...` 期望都在计时出现之前就被捕获。

+ 6 - 0
.agents/notes/implemented/feature/2026-09-10-composer-reference-previews.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-10-composer-reference-previews.md
+2026-09-10-composer-reference-previews.md: e9425851c1dc4ac0c8dc588d3b234c173eeb9262
+2026-09-10-composer-reference-previews.zh.md: c47718468bf1e38469140c12c1096b7bc5da0f40

+ 31 - 0
.agents/notes/implemented/feature/2026-09-10-composer-reference-previews.md

@@ -0,0 +1,31 @@
+# Agent Note: Composer reference previews
+
+Status: implemented
+
+English | [中文](2026-09-10-composer-reference-previews.zh.md)
+
+## Problem
+
+Users need to inspect referenced files and skill instructions while composing a message and after sending it. File chips and editable slash tokens have different editing semantics, but both need recognizable preview gestures without changing what the next prompt sends.
+
+## Decision
+
+The [input-trigger source](../../../../packages/client/ui-input-trigger/README.md) owns optional reference activation. The editor routes atomic references by their source identity and editable tokens through the current source lexicon. File and skill sources open the existing right Sidebar file resource in the composing Session. Skill discovery retains the winning provider's optional instruction-file path, avoiding body loads and guesses based on skill names or directory conventions.
+
+The [composer](../../../../packages/client/ui-conversation/README.md) shares reference hover styles while preserving atomic file chips and editable `/name` text. Clicking does not serialize or submit the draft. Invalid chips, selection gestures, and unavailable source targets retain editor handling; virtual skills remain invocable without a file preview.
+
+Sent message bubbles retain their logged skill-invocation evidence for decoration. The [Chat target](../../../../packages/client/ui-chat/README.md) opens file paths in the viewed Session and routes loaded skill names through that Session's source. The shared user-text primitive renders these references as buttons with the existing prose file-link hover and focus style; it leaves session, directory, and command references inert.
+
+## Alternatives considered
+
+**Turning skill tokens into file chips** would change editing, clipboard, and prompt semantics to solve a presentation task. The existing editable token already identifies a skill through its source lexicon.
+
+**Resolving file and skill formats inside the composer** would couple the editor to provider catalog policy and preview services. Source-owned activation keeps those dependencies with the plugins that already own reference discovery.
+
+**Loading each skill body during discovery** would add work and provider side effects before the user requests a preview. Optional path metadata is sufficient for filesystem skills and preserves virtual providers.
+
+## Consequences
+
+Preview paths are transient discovery data, never added to Session messages. The skill plugin invalidates them with its existing per-Session catalog. An uncached click awaits the shared catalog fetch and retains its Session address; invalidation and disposal cancel pending previews. Filesystem providers publish resolved instruction paths while retaining discovered reload locators and resource bases. Sidebar resource readers retain responsibility for current contents, missing-file errors, and access policy. The [workspace source-file decision](2026-09-08-present-workspace-source-files.md) remains the owner of delivered-file behavior; composer previews do not supersede it.
+
+Focused tests cover source routing, disposal, invalidation, quoted paths, selection, and unchanged draft text. The real Web composition exercises both previews, equal hover backgrounds, and deletion after opening; owner-local expected output records the skill document and a sent message. A replayed skill-invocation turn verifies both sent references after reloading history.

+ 31 - 0
.agents/notes/implemented/feature/2026-09-10-composer-reference-previews.zh.md

@@ -0,0 +1,31 @@
+# Agent Note: 输入框引用预览
+
+Status: implemented
+
+[English](2026-09-10-composer-reference-previews.md) | 中文
+
+## Problem
+
+用户需要在编写消息时和发送后查看引用文件和 skill 指令。文件标签与可编辑的斜杠文本具有不同的编辑语义,但都需要明确的预览手势,且不能改变下一条提示发送的内容。
+
+## Decision
+
+[输入触发来源](../../../../packages/client/ui-input-trigger/README.zh.md)负责可选的引用激活。编辑器按来源身份路由原子引用,按来源当前词表路由可编辑文本。文件和 skill 来源在编写消息的 Session 中打开现有右侧栏文件资源。Skill 发现保留胜出提供方可选的指令文件路径,避免加载正文或根据 skill 名称、目录惯例猜测路径。
+
+[输入框](../../../../packages/client/ui-conversation/README.zh.md)共用引用悬停样式,同时保留原子文件标签和可编辑的 `/name` 文本。点击不序列化或提交草稿。无效标签、选择手势及不可用的来源目标仍由编辑器处理;虚拟 skill 仍可调用,但没有文件预览。
+
+已发送消息的气泡保留日志中的 skill 调用证据作为装饰依据。[Chat 目标](../../../../packages/client/ui-chat/README.zh.md)在当前查看的 Session 中打开文件路径,并通过该 Session 的来源路由已加载的 skill 名称。共享用户文本组件将这些引用渲染为按钮,复用现有正文文件链接的悬停和聚焦样式;会话、目录和命令引用不提供导航。
+
+## Alternatives considered
+
+**将 skill 文本转换为文件标签**会为了展示需求而改变编辑、剪贴板和提示语义。现有可编辑文本已经能够通过来源词表标识 skill。
+
+**在输入框内解析文件和 skill 格式**会让编辑器依赖提供方目录策略及预览服务。由来源负责激活,使这些依赖留在已经负责引用发现的插件中。
+
+**发现时加载每个 skill 的正文**会在用户请求预览之前增加工作和提供方副作用。可选路径元数据足以支持文件系统 skill,并保留虚拟提供方。
+
+## Consequences
+
+预览路径是临时发现数据,不会加入 Session 消息。Skill 插件使用现有的按 Session 缓存机制使路径失效。缓存未就绪时,点击等待共享目录请求并保留所属 Session 地址;缓存失效和插件释放会取消待处理的预览。文件系统提供方公布解析后的指令路径,同时保留发现时的重新加载定位信息和资源根。侧栏资源读取器继续负责当前内容、文件缺失错误和访问策略。[工作区源文件决策](2026-09-08-present-workspace-source-files.zh.md)仍负责交付文件行为;输入框预览不取代该决策。
+
+针对性测试覆盖来源路由、释放、缓存失效、带引号路径、文本选择和草稿不变。真实 Web 组合验证两种预览、一致的悬停背景及打开后的删除操作;其所属的预期输出记录 skill 文档及已发送消息。回放的 skill 调用轮次验证刷新历史后两种已发送引用仍可预览。

+ 2 - 2
.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.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/process/2026-07-21-serial-cross-platform-ci-reference.md
-2026-07-21-serial-cross-platform-ci-reference.md: edb81b643d0cef2e5bc807005a9016324b8430ab
-2026-07-21-serial-cross-platform-ci-reference.zh.md: 41fd9c032038f2a312978acf995febfdab34aeaa
+2026-07-21-serial-cross-platform-ci-reference.md: 24022fea271d677a4588bd5dc9c7cb5b417ca8b7
+2026-07-21-serial-cross-platform-ci-reference.zh.md: c9fbc84d8ff91803acc6bcd008607fc03136b832

+ 1 - 1
.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md

@@ -28,7 +28,7 @@ The standalone [Sandbox](../../../../.github/workflows/sandbox.yml) workflow bel
 
 Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. The ci-master and Sandbox workflows keep their cross-platform references on master pushes. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value.
 
-The active serial references run on the self-hosted `vm-backup` (`serial / linux`) and `dsh-win-ci` (`serial / windows`) pools; the one remaining disabled hosted serial reference (`serial-macos`) uses `macos-latest`, and there is no standard-hosted `serial / linux` label. The master-only Wine job runs on `ubuntu-latest`, while the pull-request native jobs use the hosted `dsh-windows-2025-16core` runner under normal operation and the self-hosted `[self-hosted, dsh-win-ci, windows]` pool under failover (see the [failover runbook](2026-07-26-ci-failover-runbook.md)), with build and targeted process checks required under the [native Windows decision](2026-08-08-native-windows-pull-request-ci.md). Required pull-request jobs use portable standard capacity under the [required-CI decision](../../archived/process/2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration.
+The active serial references run on the self-hosted `vm-backup` (`serial / linux`) and `dsh-win-ci` (`serial / windows`) pools; the one remaining disabled hosted serial reference (`serial-macos`) uses `macos-latest`, and there is no standard-hosted `serial / linux` label. The master-only Wine job runs on `ubuntu-latest`, while the pull-request native jobs use the hosted `dsh-windows-2025-16core` runner under normal operation, the self-hosted `[self-hosted, dsh-win-ci, windows]` pool under the `selfhosted` failover value, and Blacksmith's Windows runners under the `blacksmith` value (see the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md); see the [failover runbook](2026-07-26-ci-failover-runbook.md)), with build and targeted process checks required under the [native Windows decision](2026-08-08-native-windows-pull-request-ci.md). Required pull-request jobs use portable standard capacity under the [required-CI decision](../../archived/process/2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration.
 
 ## Alternatives considered
 

+ 1 - 1
.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md

@@ -28,7 +28,7 @@ macOS 参考流程使用 fork 进程运行常规 Vitest 项目。macOS arm64 上
 
 master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。ci-master 与 Sandbox 工作流把跨平台参考流程保留在 master 推送上。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。
 
-当前启用的参考流程运行在公司自有 `vm-backup`(`serial / linux`)与 `dsh-win-ci`(`serial / windows`)自托管池上;唯一剩余的禁用托管参考作业(`serial-macos`)使用 `macos-latest`,且不存在标准托管的 `serial / linux` 标签。仅 master 触发的 Wine 作业在 `ubuntu-latest` 上运行,而拉取请求原生作业在正常运行下使用托管的 `dsh-windows-2025-16core` 运行器,故障切换时使用自托管 `[self-hosted, dsh-win-ci, windows]` 池(参见[故障切换手册](2026-07-26-ci-failover-runbook.zh.md)),依据[原生 Windows 决策](2026-08-08-native-windows-pull-request-ci.zh.md),其中构建与定向进程检查参与必需聚合流程。依据[必需 CI 决策](../../archived/process/2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用可移植的标准容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。
+当前启用的参考流程运行在公司自有 `vm-backup`(`serial / linux`)与 `dsh-win-ci`(`serial / windows`)自托管池上;唯一剩余的禁用托管参考作业(`serial-macos`)使用 `macos-latest`,且不存在标准托管的 `serial / linux` 标签。仅 master 触发的 Wine 作业在 `ubuntu-latest` 上运行,而拉取请求原生作业在正常运行下使用托管的 `dsh-windows-2025-16core` 运行器,在 `selfhosted` 故障切换取值下使用自托管 `[self-hosted, dsh-win-ci, windows]` 池,在 `blacksmith` 取值下使用 Blacksmith 的 Windows 运行器(见 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md);另见[故障切换手册](2026-07-26-ci-failover-runbook.zh.md)),依据[原生 Windows 决策](2026-08-08-native-windows-pull-request-ci.zh.md),其中构建与定向进程检查参与必需聚合流程。依据[必需 CI 决策](../../archived/process/2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用可移植的标准容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。
 
 ## 曾考虑的替代方案
 

+ 2 - 2
.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.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/process/2026-07-26-ci-failover-runbook.md
-2026-07-26-ci-failover-runbook.md: 559fa61bcf416bed3bd58b3ffcbc145038cdce22
-2026-07-26-ci-failover-runbook.zh.md: e2098b928b1a1f158bcbfabd940ca23a5cd0e28e
+2026-07-26-ci-failover-runbook.md: 10123fe1999c0ad03f977e1cc7c69d788a679c2b
+2026-07-26-ci-failover-runbook.zh.md: cff99e6f644bf945f8366c2a723b32222ab902b4

+ 5 - 5
.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md

@@ -10,7 +10,7 @@ The three required Linux worker jobs in [CI](../../../../.github/workflows/ci.ym
 
 ## Decision
 
-The three primary Linux jobs (`node-24`, `node-24-coverage`, `node-24-consumers`), the three `node-compat` matrix entries, and `all-checks-passed` resolve through `DSH_CI_FAILOVER_LINUX`; the native Windows jobs resolve through `DSH_CI_FAILOVER_WINDOWS`. A platform switch does not redirect the other platform. Set to `selfhosted` by a repository writer, the applicable trusted jobs select `vm-backup` or `dsh-win-ci`; otherwise they retain their workflow-defined hosted fallbacks. Node compatibility jobs require a same-repository, non-fork head and a non-Dependabot author, use isolated runtime setup, and retain `ubuntu-latest` fallback. Linux failover bounds snapshot concurrency and skips hosted package-cache restores. The verdict follows its workers so it does not remain queued on an unavailable hosted pool. Each switch is writer-manageable repository state, not a merge, so it works while checks are red. The `serial / linux (self-hosted standby)` and `serial / windows (self-hosted standby)` lanes re-prove the complete unsharded aggregates on master pushes.
+The three primary Linux jobs (`node-24`, `node-24-coverage`, `node-24-consumers`), the three `node-compat` matrix entries, and `all-checks-passed` resolve through `DSH_CI_FAILOVER_LINUX`; the native Windows jobs resolve through `DSH_CI_FAILOVER_WINDOWS`. A platform switch does not redirect the other platform. Set to `selfhosted` by a repository writer, the applicable trusted jobs select `vm-backup` or `dsh-win-ci`; the `blacksmith` value routes the participating jobs per the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md); unset or any other value retains the workflow-defined hosted fallbacks. Node compatibility jobs require a same-repository, non-fork head and a non-Dependabot author, use isolated runtime setup, and retain the `ubuntu-latest` fallback under unset and non-special values; the blacksmith branch carries none of those predicates. Under the `selfhosted` value, Linux failover bounds snapshot concurrency and skips hosted package-cache restores. The verdict follows its workers so it does not remain queued on an unavailable hosted pool. Each switch is writer-manageable repository state, not a merge, so it works while checks are red. The `serial / linux (self-hosted standby)` and `serial / windows (self-hosted standby)` lanes re-prove the complete unsharded aggregates on master pushes.
 
 The [superseded-CI cancellation policy](2026-09-09-cancel-superseded-ci.md) governs master pushes and manual runs in the same workflow/ref group, including standby drills. Rapid master updates can starve a drill before it reaches a verdict. Use the latest completed standby verdict and check its age and commit before treating it as readiness evidence; a cancelled or merely scheduled run is not proof of readiness.
 
@@ -32,9 +32,9 @@ The two switches are independent: flip only the one whose platform is degraded.
 
 1. Repository **Settings → Secrets and variables → Actions → Variables → New repository variable**: name `DSH_CI_FAILOVER_LINUX` (Linux pool outage) or `DSH_CI_FAILOVER_WINDOWS` (Windows pool outage), value `selfhosted`.
 2. Retrigger the required jobs so they re-resolve their pool. Jobs already **queued** for the hosted labels do not retarget and cannot be re-run in place, so for the documented indefinite-queue outage, cancel the stuck run and re-run all jobs, or push a new commit; "Re-run failed jobs" only helps once a job has actually failed rather than queued.
-3. That is the entire switch. Under Linux failover the workflow also drops `DSH_SNAPSHOT_MAX_CONCURRENCY` to 12 for the shared VM and skips the hosted-path pnpm cache restores because the VM's persistent store serves warm installs. Coverage uses the same four single-worker instrumented partitions and two exempt workers on both Linux pools. The Windows switch has no concurrency or cache branches; it only retargets the native Windows jobs' pool.
+3. That is the entire switch. Under the `selfhosted` Linux failover value the workflow also drops `DSH_SNAPSHOT_MAX_CONCURRENCY` to 12 for the shared VM and skips the hosted-path pnpm cache restores because the VM's persistent store serves warm installs. Coverage uses the same four single-worker instrumented partitions and two exempt workers on both Linux pools. The Windows switch has no concurrency or cache branches; it only retargets the native Windows jobs' pool.
 
-**Dependabot exception.** Both switches' selectors deliberately exclude `dependabot[bot]`: under failover, Dependabot PRs stay queued for the hosted pool rather than executing dependency-supplied code on the persistent VMs. A Dependabot PR that remains queued during an outage is expected behavior, not a failed switch; it completes when the hosted pool recovers.
+**Dependabot exception.** Both switches' `selfhosted` legs deliberately exclude `dependabot[bot]`: under self-hosted failover, Dependabot PRs stay queued for the hosted pool rather than executing dependency-supplied code on the persistent VMs. A Dependabot PR that remains queued during an outage is expected behavior, not a failed switch; it completes when the hosted pool recovers. The `blacksmith` value's branches carry no such exclusion, because Blacksmith runners are ephemeral (see the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md)).
 
 **Who can flip the variable.** GitHub's API lets any collaborator with write access manage repository variables, so each switch is writer-level, not strictly admin-only. In this repository's trust model that is not an escalation: the runner groups admit all workflows of this private, fork-disabled repository (a deliberate trade to make PR-ref failover possible at all), so any writer could already reach the VMs by pushing a branch workflow. The boundary against untrusted code is repository membership; the variables only route work for members.
 
@@ -45,11 +45,11 @@ Capacity includes the master standby, main-CI jobs, and three release-rehearsal
 
 ### Switch back
 
-Delete the `DSH_CI_FAILOVER_LINUX` or `DSH_CI_FAILOVER_WINDOWS` variable (or set it to anything other than `selfhosted`). New runs resolve back to their hosted pools. Remove any extra instances that were registered during the incident.
+Delete the `DSH_CI_FAILOVER_LINUX` or `DSH_CI_FAILOVER_WINDOWS` variable (or set it to any value other than `selfhosted` or `blacksmith`). New runs resolve back to their hosted pools. Setting it to `blacksmith` keeps the jobs on Blacksmith until the value changes. Remove any extra instances that were registered during the incident.
 
 ### Trust boundary
 
-The variables are writer-manageable repository state; a pull request event itself can neither set them nor read a different value into effect, and the selector expressions live in workflow definitions. Note that under failover, `pull_request` runs execute the PR merge ref's own workflow definition — the boundary against untrusted code is repository membership (private, forking disabled, Dependabot excluded by the selectors), not the variable. Note on runner-group policy: pinning the runner group to the master-ref workflow is **incompatible** with this failover — the failover jobs, including the Node compatibility matrix, are `pull_request` runs evaluated from PR merge refs, and a master-pinned group leaves them queued (observed live on 2026-07-27; the group was widened to all workflows of this repository to unblock the switch). A stricter runner-side policy therefore costs PR failover; the shipped posture accepts repository-scoped, all-workflow group access.
+The variables are writer-manageable repository state; a pull request event itself can neither set them nor read a different value into effect, and the selector expressions live in workflow definitions. Note that under failover, `pull_request` runs execute the PR merge ref's own workflow definition — the boundary against untrusted code is repository membership (private, forking disabled, Dependabot excluded by the `selfhosted` legs; the `blacksmith` legs carry no exclusion), not the variable. Note on runner-group policy: pinning the runner group to the master-ref workflow is **incompatible** with this failover — the failover jobs, including the Node compatibility matrix, are `pull_request` runs evaluated from PR merge refs, and a master-pinned group leaves them queued (observed live on 2026-07-27; the group was widened to all workflows of this repository to unblock the switch). A stricter runner-side policy therefore costs PR failover; the shipped posture accepts repository-scoped, all-workflow group access.
 
 ## Alternatives considered
 

+ 5 - 5
.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md

@@ -10,7 +10,7 @@ Status: implemented
 
 ## 决策
 
-三个主要 Linux 作业(`node-24`、`node-24-coverage`、`node-24-consumers`)、三个 `node-compat` 矩阵条目和 `all-checks-passed` 通过 `DSH_CI_FAILOVER_LINUX` 解析;原生 Windows 作业通过 `DSH_CI_FAILOVER_WINDOWS` 解析。一个平台的开关不会重定向另一个平台。仓库写者将变量设为 `selfhosted` 时,适用的可信作业选择 `vm-backup` 或 `dsh-win-ci`;否则保留工作流定义的托管回退。Node 兼容性作业要求同仓库且非 fork 的头部以及非 Dependabot 作者,使用隔离运行时设置,并保留 `ubuntu-latest` 回退。Linux 故障切换限制快照并发,并跳过托管软件包缓存恢复。判定作业跟随工作作业,避免继续在不可用的托管池排队。每个开关都是写者可管理的仓库状态而非一次合并,因此在检查失败时仍然有效。`serial / linux (self-hosted standby)` 与 `serial / windows (self-hosted standby)` 通道在 master 推送上重新验证完整的未分片聚合流程。
+三个主要 Linux 作业(`node-24`、`node-24-coverage`、`node-24-consumers`)、三个 `node-compat` 矩阵条目和 `all-checks-passed` 通过 `DSH_CI_FAILOVER_LINUX` 解析;原生 Windows 作业通过 `DSH_CI_FAILOVER_WINDOWS` 解析。一个平台的开关不会重定向另一个平台。仓库写者将变量设为 `selfhosted` 时,适用的可信作业选择 `vm-backup` 或 `dsh-win-ci`;`blacksmith` 取值按 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md) 路由参与切换的作业;未设置或任何其它值保留工作流定义的托管回退。Node 兼容性作业要求同仓库且非 fork 的头部以及非 Dependabot 作者,使用隔离运行时设置,并在未设置与非特殊值下保留 `ubuntu-latest` 回退;blacksmith 分支不带上述任何条件。在 `selfhosted` 取值下,Linux 故障切换会限制快照并发,并跳过托管软件包缓存恢复。判定作业跟随工作作业,避免继续在不可用的托管池排队。每个开关都是写者可管理的仓库状态而非一次合并,因此在检查失败时仍然有效。`serial / linux (self-hosted standby)` 与 `serial / windows (self-hosted standby)` 通道在 master 推送上重新验证完整的未分片聚合流程。
 
 [被取代 CI 的取消策略](2026-09-09-cancel-superseded-ci.zh.md) 管理同一工作流/引用组内的 master 推送和手动运行,包括热备演练。master 快速更新可能让演练因反复被取消而始终无法得出结论。判断就绪状态时,使用最近一次已完成的热备结论,并核对其时间和提交;已取消或仅被调度的运行不构成就绪证据。
 
@@ -32,9 +32,9 @@ Status: implemented
 
 1. 仓库 **Settings → Secrets and variables → Actions → Variables → New repository variable**:名称 `DSH_CI_FAILOVER_LINUX`(Linux 池故障)或 `DSH_CI_FAILOVER_WINDOWS`(Windows 池故障),值 `selfhosted`。
 2. 重新触发必需作业,使其重新解析运行器池。已经为托管标签**排队**的作业不会重定向,也无法原地 re-run,因此对于本手册所述的无限排队故障,应取消卡住的运行并 re-run all jobs,或推送一个新提交;“Re-run failed jobs”只有在作业真正失败(而非仍在排队)时才有用。
-3. 切换到此完成。Linux 故障切换状态下,工作流还会把 `DSH_SNAPSHOT_MAX_CONCURRENCY` 降为 12,以限制共享虚拟机上的争抢,并跳过托管路径的 pnpm 缓存恢复,因为虚拟机的持久 store 会直接提供热安装。覆盖率在两个 Linux 池上都使用 4 个单 worker 插桩分区与 2 个豁免 worker。Windows 开关没有并发或缓存分支;它只重定向原生 Windows 作业的运行器池。
+3. 切换到此完成。在 `selfhosted` 的 Linux 故障切换取值下,工作流还会把 `DSH_SNAPSHOT_MAX_CONCURRENCY` 降为 12,以限制共享虚拟机上的争抢,并跳过托管路径的 pnpm 缓存恢复,因为虚拟机的持久 store 会直接提供热安装。覆盖率在两个 Linux 池上都使用 4 个单 worker 插桩分区与 2 个豁免 worker。Windows 开关没有并发或缓存分支;它只重定向原生 Windows 作业的运行器池。
 
-**Dependabot 例外。**两个开关的选择器都刻意排除了 `dependabot[bot]`:故障切换期间,Dependabot 拉取请求继续在托管池排队,而不是把依赖项提供的代码放到持久化虚拟机上执行。故障期间 Dependabot PR 持续排队是预期行为而非切换失败;托管池恢复后它会自行完成。
+**Dependabot 例外。**两个开关的 `selfhosted` 腿都刻意排除 `dependabot[bot]`:自托管故障切换期间,Dependabot 拉取请求继续在托管池排队,而不是把依赖项提供的代码放到持久化虚拟机上执行。故障期间 Dependabot PR 持续排队是预期行为而非切换失败;托管池恢复后它会自行完成。`blacksmith` 取值下的分支不带此类排除,因为 Blacksmith 运行器是临时的(见 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md))。
 
 **谁能扳动这个变量。**GitHub 的 API 允许任何具有写权限的协作者管理仓库变量,因此每个开关实际是写者级而非严格的管理员级。在本仓库的信任模型下这并不构成升权:runner group 接纳本私有、禁 fork 仓库的全部工作流(这是让 PR 引用的故障切换得以成立的刻意取舍),因此任何写者本就可以通过推送分支工作流触达这台虚拟机。抵御不可信代码的边界是仓库成员资格;变量只是为成员路由工作。
 
@@ -45,11 +45,11 @@ Linux 开关启用期间,容量需覆盖 master 热备、主 CI 作业,以
 
 ### 切回
 
-删除 `DSH_CI_FAILOVER_LINUX` 或 `DSH_CI_FAILOVER_WINDOWS` 变量(或改为 `selfhosted` 以外的任何值),新的运行即解析回各自的托管池。若故障期间追加注册过实例,将其移除。
+删除 `DSH_CI_FAILOVER_LINUX` 或 `DSH_CI_FAILOVER_WINDOWS` 变量(或改为 `selfhosted` 与 `blacksmith` 之外的任何值),新的运行即解析回各自的托管池。设为 `blacksmith` 会让作业留在 Blacksmith,直到该值改变。若故障期间追加注册过实例,将其移除。
 
 ### 信任边界
 
-这些变量是写者可管理的仓库状态;`pull_request` 事件本身既不能设置它们,也不能让不同的值生效,选择器表达式存在于工作流定义中。需要注意:故障切换期间,`pull_request` 运行执行的是 PR merge 引用自带的工作流定义——抵御不可信代码的边界是仓库成员资格(私有、禁 fork、选择器排除 Dependabot),而非该变量。关于 runner group 策略的说明:把 runner group 绑定到 master 引用的工作流与本故障切换机制**不兼容**——包括 Node 兼容性矩阵在内的故障切换作业是从 PR merge 引用求值的 `pull_request` 运行,master 绑定的组会让它们持续排队(2026-07-27 实际故障中亲历;当时将组放宽为本仓库全部工作流才疏通了切换)。更严格的运行器侧策略以牺牲 PR 故障切换为代价;当前采用的形态是仓库范围、全工作流的组访问。
+这些变量是写者可管理的仓库状态;`pull_request` 事件本身既不能设置它们,也不能让不同的值生效,选择器表达式存在于工作流定义中。需要注意:故障切换期间,`pull_request` 运行执行的是 PR merge 引用自带的工作流定义——抵御不可信代码的边界是仓库成员资格(私有、禁 fork、Dependabot 由 `selfhosted` 腿排除;`blacksmith` 腿不带排除),而非该变量。关于 runner group 策略的说明:把 runner group 绑定到 master 引用的工作流与本故障切换机制**不兼容**——包括 Node 兼容性矩阵在内的故障切换作业是从 PR merge 引用求值的 `pull_request` 运行,master 绑定的组会让它们持续排队(2026-07-27 实际故障中亲历;当时将组放宽为本仓库全部工作流才疏通了切换)。更严格的运行器侧策略以牺牲 PR 故障切换为代价;当前采用的形态是仓库范围、全工作流的组访问。
 
 ## 曾考虑的替代方案
 

+ 2 - 2
.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.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/process/2026-08-08-native-windows-pull-request-ci.md
-2026-08-08-native-windows-pull-request-ci.md: 690f8e6f9b13fa7e72240a42ff482bd83f9088b0
-2026-08-08-native-windows-pull-request-ci.zh.md: 9efa3cbcf33b6c12e4eed253b6a0546c79b768fe
+2026-08-08-native-windows-pull-request-ci.md: e4fc7cab8c274148191632e8cc125ac75f2ec1d5
+2026-08-08-native-windows-pull-request-ci.zh.md: 27ad602c3748f2920e6b63f271a6a38b11c0ab77

+ 1 - 1
.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md

@@ -14,7 +14,7 @@ A coverage audit found that stale branch state had restored temporary exclusions
 
 The master-only `windows` job in [ci-master.yml](../../../../.github/workflows/ci-master.yml) runs `windows node 24 / wine` on `ubuntu-latest`. It retains the checksum-verified Windows Node, Wine apt and pnpm caches, a hoisted install confined to a workspace snapshot, and the [shared Wine gate script](../../../../scripts/wine-windows-gates.sh) that runs the workspace build and production site. Node distribution transfers use bounded retries; when nodejs.org stalls on the large archive, a range-capable transport mirror resumes the same bytes, but nodejs.org remains the version and SHA-256 authority and the archive is never promoted before that checksum passes. Wine is outside the PR aggregate under the [master-only platform policy](2026-09-06-master-only-platform-ci.md). The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) preserves its measured trade-offs, while this note owns the current dual topology.
 
-Every pull request also starts four independent native jobs on the organization-owned `dsh-windows-2025-16core` runner: `windows-build`, `windows-coverage`, `windows-native-tests`, and `windows-observational`. Each job enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs its inventory under native PowerShell. The Windows failover variable retargets all four jobs to the in-house pool. Per-job deadlines range from 60 to 120 minutes and bound stuck work without treating a performance target as a correctness deadline.
+Every pull request also starts four independent native jobs on the organization-owned `dsh-windows-2025-16core` runner: `windows-build`, `windows-coverage`, `windows-native-tests`, and `windows-observational`. Each job enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs its inventory under native PowerShell. The Windows failover variable (`DSH_CI_FAILOVER_WINDOWS`) retargets all four jobs to the in-house pool under `selfhosted` and to Blacksmith's Windows runners under `blacksmith` (see the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md)). Per-job deadlines range from 60 to 120 minutes and bound stuck work without treating a performance target as a correctness deadline.
 
 `windows-build` and `windows-native-tests` are dependencies of `all checks passed`; their workspace-build and targeted native-process results are blocking. `windows-coverage` remains an ordinary job but is absent from aggregate `needs`, so its 100%-per-file result stays red and visible without delaying the required verdict. `windows-observational` is also absent from aggregate `needs` and uses `continue-on-error` because Linux owns the blocking static, documentation, package, and built-artifact verdicts.
 

+ 1 - 1
.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md

@@ -14,7 +14,7 @@ Wine 在 Linux 内核与区分大小写的 ext4 之上采用 hoisted 依赖布
 
 [ci-master.yml](../../../../.github/workflows/ci-master.yml) 中仅 master 触发的 `windows` 作业在 `ubuntu-latest` 上运行 `windows node 24 / wine`。它保留经过校验和验证的 Windows Node、Wine apt 与 pnpm 缓存、仅限工作区快照的 hoisted 安装,以及运行工作区构建与生产网站的[共享 Wine 门禁脚本](../../../../scripts/wine-windows-gates.sh)。Node 分发文件传输采用有界重试;nodejs.org 的大文件传输停滞时,由支持范围请求的传输镜像续传相同字节,但版本和 SHA-256 权威仍属于 nodejs.org,归档通过该校验前绝不会投入使用。根据[仅 master 平台策略](2026-09-06-master-only-platform-ci.zh.md),Wine 不参与 PR 聚合。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)保留其实测取舍,而本文负责当前双通道拓扑。
 
-每个拉取请求还会在组织自有的 `dsh-windows-2025-16core` 运行器上启动 4 个相互独立的原生作业:`windows-build`、`windows-coverage`、`windows-native-tests` 与 `windows-observational`。每个作业都会为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行自己的清单。Windows 故障切换变量会把这 4 个作业全部重定向到公司内部运行器池。各作业采用 60 至 120 分钟的截止时间,以约束卡住的工作,同时不把性能目标当作正确性截止时间。
+每个拉取请求还会在组织自有的 `dsh-windows-2025-16core` 运行器上启动 4 个相互独立的原生作业:`windows-build`、`windows-coverage`、`windows-native-tests` 与 `windows-observational`。每个作业都会为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行自己的清单。Windows 故障切换变量(`DSH_CI_FAILOVER_WINDOWS`)在 `selfhosted` 下把这 4 个作业全部重定向到公司内部运行器池,在 `blacksmith` 下重定向到 Blacksmith 的 Windows 运行器(见 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md))。各作业采用 60 至 120 分钟的截止时间,以约束卡住的工作,同时不把性能目标当作正确性截止时间。
 
 `windows-build` 与 `windows-native-tests` 是 `all checks passed` 的依赖项;其工作区构建和定向原生进程结果具有阻断性。`windows-coverage` 仍是常规作业,但不在聚合流程的 `needs` 中,因此逐文件 100% 覆盖率结果会保持红灯并可见,却不会延迟必需判定。`windows-observational` 同样不在聚合流程的 `needs` 中,并使用 `continue-on-error`,因为静态检查、文档、包与构建产物的阻断性判定由 Linux 负责。
 

+ 2 - 2
.agents/notes/implemented/process/2026-09-06-node-compatibility-selfhosted.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/process/2026-09-06-node-compatibility-selfhosted.md
-2026-09-06-node-compatibility-selfhosted.md: c78092834123b837d100814be9beba52c1a41397
-2026-09-06-node-compatibility-selfhosted.zh.md: 6dcff8aa197c0995e4e90d2d56179340a41bc783
+2026-09-06-node-compatibility-selfhosted.md: c361d21d3e1093dd5c87bf2ba085bdd1acacb5d8
+2026-09-06-node-compatibility-selfhosted.zh.md: 80a9d8519d6084f5e01944b277882a51b2291e94

+ 1 - 1
.agents/notes/implemented/process/2026-09-06-node-compatibility-selfhosted.md

@@ -10,7 +10,7 @@ The Node 22.19, 24.9, and 26 compatibility jobs consume hosted Linux minutes eve
 
 ## Decision
 
-[CI](../../../../.github/workflows/ci.yml) applies the Linux failover variable to these three jobs, requiring a non-Dependabot author and a non-fork head repository matching the current repository. The standard hosted fallback remains available. These predicates constrain this job, not every workflow admitted to the pool. Both repository identity and fork status remain explicit to preserve its trust restriction if repository settings change; existing sibling selectors are outside this migration.
+[CI](../../../../.github/workflows/ci.yml) applies the Linux failover variable to these three jobs, requiring a non-Dependabot author and a non-fork head repository matching the current repository. The standard hosted fallback remains available. These predicates constrain this job, not every workflow admitted to the pool. Both repository identity and fork status remain explicit to preserve its trust restriction if repository settings change; existing sibling selectors are outside this migration. The `blacksmith` failover value's branch drops those predicates: it targets ephemeral Blacksmith runners, so repository identity and fork status do not gate it (see the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md)).
 
 The temporary tool cache trades repeated Node downloads for isolation across concurrent runners and Node versions. A setup-node-only [ESM preload](../../../../scripts/ci-compatible-toolcache.mjs) assigns the cache inside the action process: the Actions runner overwrites reserved environment variables after reading step configuration. An executed path check rejects installations outside runner temp; compatibility processes do not inherit the preload. pnpm keeps its existing private setup destination and persistent content-addressed store. Compile caches and node-gyp headers use runner temp before the first pnpm invocation. No global Node symlink or system package changes are introduced. Hosted jobs retain their tool and package caching; self-hosted jobs do not restore or upload hosted package caches. The runner owns temporary-directory cleanup between jobs, and the shared image supplies native npm packages’ compiler and Python prerequisites.
 

+ 1 - 1
.agents/notes/implemented/process/2026-09-06-node-compatibility-selfhosted.zh.md

@@ -10,7 +10,7 @@ Status: implemented
 
 ## 决策
 
-[CI](../../../../.github/workflows/ci.yml) 将 Linux 故障切换变量应用于这三个作业,要求作者不是 Dependabot,且非 fork 的头部仓库与当前仓库相同。标准托管回退仍然可用。这些条件约束本作业,而非所有可进入该池的工作流。仓库身份和 fork 状态均显式保留,以便在仓库设置改变时保持本作业的信任限制;现有兄弟选择器不属于本次迁移范围。
+[CI](../../../../.github/workflows/ci.yml) 将 Linux 故障切换变量应用于这三个作业,要求作者不是 Dependabot,且非 fork 的头部仓库与当前仓库相同。标准托管回退仍然可用。这些条件约束本作业,而非所有可进入该池的工作流。仓库身份和 fork 状态均显式保留,以便在仓库设置改变时保持本作业的信任限制;现有兄弟选择器不属于本次迁移范围。`blacksmith` 故障切换取值下的分支放弃这些条件:它面向临时的 Blacksmith 运行器,因此仓库身份与 fork 状态不参与门控(见 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md))。
 
 临时工具缓存以重复下载 Node 为代价,换取并发运行器与 Node 版本之间的隔离。仅用于 setup-node 的 [ESM 预加载模块](../../../../scripts/ci-compatible-toolcache.mjs) 在 action 进程内指定缓存:Actions 运行器在读取步骤配置后会覆盖保留的环境变量。实际执行的路径检查拒绝运行器临时目录之外的安装;兼容性进程不继承预加载设置。pnpm 保留现有的私有安装目录和持久化内容寻址 store。编译缓存与 node-gyp 头文件在首次调用 pnpm 前就使用运行器临时目录。不引入全局 Node 符号链接或系统软件包变更。托管作业保留其工具与软件包缓存;自托管作业不恢复或上传托管软件包缓存。运行器负责作业之间的临时目录清理,共享镜像提供原生 npm 软件包所需的编译器和 Python 前置依赖。
 

+ 6 - 0
.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.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/process/2026-09-09-blacksmith-failover-leg.md
+2026-09-09-blacksmith-failover-leg.md: eaaa6f4d5c8326ae7686dc9f485143781d065313
+2026-09-09-blacksmith-failover-leg.zh.md: 562aed96327a86c24e38b502f2ef4756a11b91e1

Разлика између датотеке није приказан због своје велике величине
+ 26 - 0
.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md


Разлика између датотеке није приказан због своје велике величине
+ 26 - 0
.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.zh.md


+ 6 - 0
.agents/notes/implemented/process/2026-09-10-approval-review-workflow-identity.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/process/2026-09-10-approval-review-workflow-identity.md
+2026-09-10-approval-review-workflow-identity.md: 77164134e826acdf647db352cf73df2480055505
+2026-09-10-approval-review-workflow-identity.zh.md: 736851d6f3612080fd67ed6b72e1b8b667948d73

+ 25 - 0
.agents/notes/implemented/process/2026-09-10-approval-review-workflow-identity.md

@@ -0,0 +1,25 @@
+# Agent Note: Identify approval review workflows by file path
+
+Status: implemented
+
+English | [中文](2026-09-10-approval-review-workflow-identity.zh.md)
+
+## Problem
+
+GitHub can populate a workflow run's `name` with its expanded `run-name`. The approval review workflow includes the pull-request number in that title, so comparing `workflow_run.name` with the static workflow name rejects valid review events before refreshing the approval status.
+
+## Decision
+
+The [approval publisher](../../../../.github/review-ownership/check-approval.mjs) identifies the review-event workflow by its exact `workflow_run.path`. It also requires a successful `pull_request_review` run, parses the pull-request number from `display_title`, validates any supplied pull-request association, and compares the current pull-request head with the reviewed head before evaluating approvals.
+
+## Alternatives considered
+
+**Accept a name prefix.** A display name does not identify the workflow file; another workflow can use the same title.
+
+**Remove the numbered run title.** The title supplies the pull-request number when GitHub returns an empty `pull_requests` array. Removing it requires a different handoff mechanism.
+
+## Consequences
+
+Run-title expansion does not prevent approval refreshes, while an unexpected workflow file still fails validation. Moving the review-event workflow requires updating the publisher's expected path.
+
+[Approval policy tests](../../../../.github/review-ownership/check-approval.test.mjs) cover a numbered run name, invalid source paths and events, unsuccessful runs, invalid titles, and superseded heads. The [approval outcome policy](2026-09-09-blocked-weighted-approvals-remain-pending.md) continues to own pending and successful status semantics.

+ 25 - 0
.agents/notes/implemented/process/2026-09-10-approval-review-workflow-identity.zh.md

@@ -0,0 +1,25 @@
+# Agent Note: 按文件路径识别审批评审工作流
+
+Status: implemented
+
+[English](2026-09-10-approval-review-workflow-identity.md) | 中文
+
+## 问题
+
+GitHub 可能用展开后的 `run-name` 填充工作流运行的 `name`。审批评审工作流在该标题中包含拉取请求编号,因此将 `workflow_run.name` 与静态工作流名称比较,会在刷新审批状态前拒绝有效的评审事件。
+
+## 决策
+
+[审批发布器](../../../../.github/review-ownership/check-approval.mjs) 按精确的 `workflow_run.path` 识别评审事件工作流。它还要求该运行由 `pull_request_review` 触发且成功完成,从 `display_title` 解析拉取请求编号,验证提供的拉取请求关联,并在计算审批结果前将拉取请求当前的头提交与已评审的头提交进行比较。
+
+## 考虑过的替代方案
+
+**接受名称前缀。** 显示名称无法识别工作流文件;另一个工作流可以使用相同的标题。
+
+**删除带编号的运行标题。** 当 GitHub 返回空的 `pull_requests` 数组时,标题提供拉取请求编号。删除它需要另一种传递机制。
+
+## 影响
+
+运行标题的展开不会阻止审批刷新,而非预期的工作流文件仍无法通过验证。移动评审事件工作流时,需要更新发布器预期的路径。
+
+[审批策略测试](../../../../.github/review-ownership/check-approval.test.mjs) 覆盖带编号的运行名称、无效的来源路径和事件、未成功的运行、无效标题以及已被替代的头提交。[审批结果策略](2026-09-09-blocked-weighted-approvals-remain-pending.zh.md) 继续负责待定与成功状态的语义。

+ 2 - 2
.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.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/testing/2026-09-08-ci-readiness-and-completion.md
-2026-09-08-ci-readiness-and-completion.md: 2249df5467189975aca2d73ec56c6cf82ec7b62f
-2026-09-08-ci-readiness-and-completion.zh.md: ccf8e7c16903b66e39bc48e99460e5c5179ab51b
+2026-09-08-ci-readiness-and-completion.md: c72048ef90b987e7d27992b1754736b2ce895093
+2026-09-08-ci-readiness-and-completion.zh.md: 1188d66249b3092416435da2e8496a657ad00677

+ 4 - 0
.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.md

@@ -14,6 +14,8 @@ The [ACP coverage run](https://github.com/deepseek-harness/deepseek-harness/acti
 
 A [worker-runtime coverage failure](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34248221544/job/102135631932) exhausts the slow-binding fixture's one-second compute allowance. Concurrent native Windows reproductions exceed that allowance before calling the binding. Worker initialization contributes measured active time; the delayed binding contributes idle time.
 
+The [Windows coverage run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34324325375/job/102377982193) reports an SDK subprocess exit beyond a fixture's 200 ms confirmation window and an Inspector Worker startup beyond its ten-second default. Neither the protocol-error routing case nor the Cordis tree projection case measures those latency guarantees.
+
 ## Decision
 
 The [webhook browser test](../../../../apps/web/tests/github-ready-review.e2e.ts) observes the model request caused by delivery before checking Session registration. The [feedback test](../../../../apps/web/tests/feedback-command.e2e.ts) waits for the empty composer and enabled attachment control before comparing ARIA output. Matching consecutive snapshots cannot prove that the command RPC has settled: its event stream can publish the acknowledgement first.
@@ -28,6 +30,8 @@ The [subagent teardown decision](2026-09-07-subagent-teardown-test-budgets.md) o
 
 The [worker-runtime binding test](../../../../packages/code-runtime/code-runtime-worker-thread/tests/runtime.spec.ts) allows five seconds of compute for source-worker initialization and delays the binding for 6.5 seconds. Charging that idle delay would still exceed the entire compute allowance. The case retains its 15-second test limit and 30-second wall ceiling, registers Context and reply-timer cleanup, and leaves the hot-loop, decoy-dispatch, wall-ceiling, and abort controls at their existing limits. Production budgets remain unchanged.
 
+The [SDK subagent protocol-error test](../../../../packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts) uses the provider's normal shutdown and exit grace periods and registers disposal before its assertions. The [Inspector tree tests](../../../../packages/experimental/inspector/tests/cordis-tree.host.spec.ts) pass the active test budget to Worker startup and register cleanup while startup is still pending. A cancelled test cannot receive a late-ready handle; cleanup awaits initialization and closes a successfully started Worker. Failed initialization already terminates the Worker before rejecting. A controlled late-start test verifies cancellation and closure through the real Worker's HTTP endpoint. Production defaults remain unchanged.
+
 ## Alternatives considered
 
 **Larger independent waits.** Rejected where a completion promise already exists. A separate polling deadline continues to compete with the execution lane's budget.

+ 4 - 0
.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.zh.md

@@ -14,6 +14,8 @@ Status: implemented
 
 一次 [worker runtime coverage 失败](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34248221544/job/102135631932)耗尽了慢 binding 夹具的一秒计算额度。原生 Windows 并发复现在调用 binding 前已超过该额度。Worker 初始化会累计所测的活跃时间;延迟的 binding 累计空闲时间。
 
+[Windows 覆盖率运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34324325375/job/102377982193)报告了 SDK 子进程退出超过测试设置的 200 毫秒确认期限,以及 Inspector Worker 启动超过十秒默认期限。协议错误转发用例和 Cordis 树投影用例都不衡量这些延迟保证。
+
 ## 决策
 
 [Webhook 浏览器测试](../../../../apps/web/tests/github-ready-review.e2e.ts)观察投递触发的模型请求后再检查 Session 注册。[反馈测试](../../../../apps/web/tests/feedback-command.e2e.ts)在比较 ARIA 输出前等待输入框清空且附件按钮启用。连续两次快照相同不能证明命令 RPC 已完成:事件流可能先发布确认消息。
@@ -28,6 +30,8 @@ Status: implemented
 
 [Worker runtime binding 测试](../../../../packages/code-runtime/code-runtime-worker-thread/tests/runtime.spec.ts)为源码 worker 初始化保留五秒计算额度,并将 binding 延迟设为 6.5 秒。若将该空闲延迟计费,仍会超过整个计算额度。用例保留 15 秒测试期限与 30 秒墙钟上限,登记 Context 和回复定时器的清理,并保持热循环、诱饵 dispatch、墙钟上限及取消控制用例的原有限制。生产预算不变。
 
+[SDK 子 Agent 协议错误测试](../../../../packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts)使用提供方正常的关闭和退出等待时间,并在断言前登记清理。[Inspector 树测试](../../../../packages/experimental/inspector/tests/cordis-tree.host.spec.ts)将当前测试预算传给 Worker 启动,并在启动尚未完成时登记清理。取消后的测试不会收到随后才就绪的实例;清理等待初始化完成,并关闭成功启动的 Worker。初始化失败时,启动操作会在拒绝前终止 Worker。受控的延迟启动测试通过真实 Worker 的 HTTP 端点验证取消和关闭。生产默认值不变。
+
 ## 考虑过的替代方案
 
 **增大独立等待时限。** 已有完成 Promise 时不采用。独立轮询期限仍会与执行通道的预算竞争。

+ 1 - 1
.github/AGENTS.md

@@ -1,3 +1,3 @@
 # AGENTS.md — GitHub Actions
 
-Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. Native Windows build and process checks contribute to the pull-request `all checks passed` verdict; Wine runs Windows Node on hosted Linux only in `ci-master.yml`. Python runtime CI checks Linux/Windows x64 on pull requests and Linux ARM64 plus both macOS architectures on master pushes; releases retain all five targets ([platform policy](../.agents/notes/implemented/process/2026-09-06-master-only-platform-ci.md)). `ci.yml` is pull-request-only. Master-only platform checks, Linux/Windows self-hosted standbys, and manual runner benchmarks live in `ci-master.yml`, which listens to master pushes and `workflow_dispatch`, not `pull_request`; separating workflow triggers keeps master-only jobs out of PR check panels. The master standbys validate the self-hosted failover targets; preserve the existing per-platform switches and Dependabot hosted fallback ([failover runbook](../.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md)).
+Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. Native Windows build and process checks contribute to the pull-request `all checks passed` verdict; Wine runs Windows Node on hosted Linux only in `ci-master.yml`. Python runtime CI checks Linux/Windows x64 on pull requests and Linux ARM64 plus both macOS architectures on master pushes; releases retain all five targets ([platform policy](../.agents/notes/implemented/process/2026-09-06-master-only-platform-ci.md)). `ci.yml` is pull-request-only. Master-only platform checks, Linux/Windows self-hosted standbys, and manual runner benchmarks live in `ci-master.yml`, which listens to master pushes and `workflow_dispatch`, not `pull_request`; separating workflow triggers keeps master-only jobs out of PR check panels. The master standbys validate the self-hosted failover targets; preserve the existing per-platform switches (values `selfhosted` for the in-house standbys and `blacksmith` for Blacksmith's hosted runners) and the Dependabot hosted fallback under the `selfhosted` values ([failover runbook](../.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md), [blacksmith failover leg note](../.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md)).

+ 1 - 1
.github/review-ownership/README.md

@@ -27,7 +27,7 @@ The publisher runs when a pull request opens, synchronizes, reopens, becomes rea
 
 ## Security
 
-The status-writing job checks out only the repository default branch. It does not check out or execute pull-request code and does not use repository secrets. The review-event workflow has no `GITHUB_TOKEN` permissions and passes only a decimal pull-request number in its run title. The publisher rejects an invalid run title and a number that does not resolve to the workflow run's current pull-request head. Pull-request reviews are treated as API data and escaped in logs.
+The status-writing job checks out only the repository default branch. It does not check out or execute pull-request code and does not use repository secrets. The review-event workflow has no `GITHUB_TOKEN` permissions and passes only a decimal pull-request number in its run title. The publisher accepts only successful `pull_request_review` runs from the review-event workflow file, identified by `workflow_run.path`; GitHub can populate `workflow_run.name` with the expanded run title. The publisher rejects an invalid run title and a number that does not resolve to the workflow run's current pull-request head. Pull-request reviews are treated as API data and escaped in logs.
 
 Approval policy changes take effect only after they merge into the default branch. This prevents an untrusted pull request from changing the program or policy for its own run.
 

+ 4 - 2
.github/review-ownership/check-approval.mjs

@@ -208,13 +208,15 @@ export async function runApprovalCheck({ event, policySource, api, runUrl, write
 }
 
 /**
- * Resolve the reviewed pull request from a completed review-event workflow run.
+ * Resolve the reviewed pull request from a completed run of the review-event workflow file.
  * @param {{event: unknown, api: (path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>}} options Trusted workflow inputs.
  * @returns {Promise<Record<string, unknown> | null>} Event with a current pull request, or null after the pull-request head changes.
  */
 export async function approvalEventFromWorkflowRun({ event, api }) {
   const repository = repositoryFromEvent(event)
-  if (!isRecord(event.workflow_run) || event.workflow_run.name !== 'weighted-approval-review-event'
+  // GitHub can expand run-name into name; the file path identifies the source workflow.
+  if (!isRecord(event.workflow_run)
+    || event.workflow_run.path !== '.github/workflows/weighted-approval-review-event.yml'
     || event.workflow_run.event !== 'pull_request_review' || event.workflow_run.conclusion !== 'success') {
     throw new Error('event has no successful weighted approval review workflow run')
   }

+ 17 - 1
.github/review-ownership/check-approval.test.mjs

@@ -78,7 +78,8 @@ test('resolves a review workflow run to the current pull request and rejects sta
   const workflowRunEvent = {
     repository: { full_name: 'deepseek-harness/deepseek-harness' },
     workflow_run: {
-      name: 'weighted-approval-review-event',
+      name: 'weighted-approval-review-event:42',
+      path: '.github/workflows/weighted-approval-review-event.yml',
       event: 'pull_request_review',
       conclusion: 'success',
       head_sha: HEAD_SHA,
@@ -95,6 +96,21 @@ test('resolves a review workflow run to the current pull request and rejects sta
   })
   assert.equal(current.pull_request.number, 42)
 
+  for (const invalidRun of [
+    { path: '.github/workflows/other.yml' },
+    { path: undefined },
+    { event: 'push' },
+    { conclusion: 'failure' },
+  ]) {
+    await assert.rejects(approvalEventFromWorkflowRun({
+      event: {
+        ...workflowRunEvent,
+        workflow_run: { ...workflowRunEvent.workflow_run, ...invalidRun },
+      },
+      api: async () => { throw new Error('invalid source must not call GitHub') },
+    }), /successful weighted approval review workflow run/u)
+  }
+
   assert.equal(await approvalEventFromWorkflowRun({
     event: workflowRunEvent,
     api: async () => ({

+ 42 - 2
.github/workflows/ci-master.yml

@@ -281,7 +281,15 @@ jobs:
   # The named pools are restricted at the organization level to this repository.
   larger-runner-benchmark:
     if: github.event_name == 'workflow_dispatch' && inputs.suite == 'larger-runner-benchmark'
-    runs-on: ${{ matrix.runner }}
+    # Default measures the repository's own fleet tiers; under the matching
+    # platform's blacksmith failover value the tiers Blacksmith offers (up to
+    # 32 vCPU) move onto their Blacksmith equivalents, while the 64/96-core
+    # rows keep the fleet labels because Blacksmith has no such tier.
+    runs-on: >-
+      ${{ (matrix.platform == 'linux' && vars.DSH_CI_FAILOVER_LINUX == 'blacksmith'
+            || matrix.platform == 'windows' && vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith')
+          && matrix.blacksmith
+          || matrix.runner }}
     timeout-minutes: 15
     strategy:
       fail-fast: false
@@ -291,50 +299,62 @@ jobs:
           - platform: linux
             cores: '4'
             runner: dsh-ubuntu-24-04-4core
+            blacksmith: blacksmith-4vcpu-ubuntu-2404
             workload: typecheck
           - platform: linux
             cores: '8'
             runner: dsh-ubuntu-24-04-8core
+            blacksmith: blacksmith-8vcpu-ubuntu-2404
             workload: typecheck
           - platform: linux
             cores: '16'
             runner: dsh-ubuntu-24-04-16core
+            blacksmith: blacksmith-16vcpu-ubuntu-2404
             workload: typecheck
           - platform: linux
             cores: '32'
             runner: dsh-ubuntu-24-04-32core
+            blacksmith: blacksmith-32vcpu-ubuntu-2404
             workload: typecheck
           - platform: linux
             cores: '64'
             runner: dsh-ubuntu-24-04-64core
+            blacksmith: ''
             workload: typecheck
           - platform: linux
             cores: '96'
             runner: dsh-ubuntu-24-04-96core
+            blacksmith: ''
             workload: typecheck
           - platform: windows
             cores: '4'
             runner: dsh-windows-2025-4core
+            blacksmith: blacksmith-4vcpu-windows-2025
             workload: production-site
           - platform: windows
             cores: '8'
             runner: dsh-windows-2025-8core
+            blacksmith: blacksmith-8vcpu-windows-2025
             workload: production-site
           - platform: windows
             cores: '16'
             runner: dsh-windows-2025-16core
+            blacksmith: blacksmith-16vcpu-windows-2025
             workload: production-site
           - platform: windows
             cores: '32'
             runner: dsh-windows-2025-32core
+            blacksmith: blacksmith-32vcpu-windows-2025
             workload: production-site
           - platform: windows
             cores: '64'
             runner: dsh-windows-2025-64core
+            blacksmith: ''
             workload: production-site
           - platform: windows
             cores: '96'
             runner: dsh-windows-2025-96core
+            blacksmith: ''
             workload: production-site
     steps:
       - uses: actions/checkout@v6
@@ -372,7 +392,15 @@ jobs:
   # Windows runs both blocking build targets concurrently through run-gates.
   consolidated-runner-benchmark:
     if: github.event_name == 'workflow_dispatch' && inputs.suite == 'consolidated-runner-benchmark'
-    runs-on: ${{ matrix.runner }}
+    # Default measures the repository's own fleet tiers; under the matching
+    # platform's blacksmith failover value the tiers Blacksmith offers (up to
+    # 32 vCPU) move onto their Blacksmith equivalents, while the 64/96-core
+    # rows keep the fleet labels because Blacksmith has no such tier.
+    runs-on: >-
+      ${{ (matrix.platform == 'linux' && vars.DSH_CI_FAILOVER_LINUX == 'blacksmith'
+            || matrix.platform == 'windows' && vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith')
+          && matrix.blacksmith
+          || matrix.runner }}
     timeout-minutes: 15
     strategy:
       fail-fast: false
@@ -382,50 +410,62 @@ jobs:
           - platform: linux
             cores: '4'
             runner: dsh-ubuntu-24-04-4core
+            blacksmith: blacksmith-4vcpu-ubuntu-2404
             workers: '4'
           - platform: linux
             cores: '8'
             runner: dsh-ubuntu-24-04-8core
+            blacksmith: blacksmith-8vcpu-ubuntu-2404
             workers: '8'
           - platform: linux
             cores: '16'
             runner: dsh-ubuntu-24-04-16core
+            blacksmith: blacksmith-16vcpu-ubuntu-2404
             workers: '16'
           - platform: linux
             cores: '32'
             runner: dsh-ubuntu-24-04-32core
+            blacksmith: blacksmith-32vcpu-ubuntu-2404
             workers: '32'
           - platform: linux
             cores: '64'
             runner: dsh-ubuntu-24-04-64core
+            blacksmith: ''
             workers: '32'
           - platform: linux
             cores: '96'
             runner: dsh-ubuntu-24-04-96core
+            blacksmith: ''
             workers: '32'
           - platform: windows
             cores: '4'
             runner: dsh-windows-2025-4core
+            blacksmith: blacksmith-4vcpu-windows-2025
             workers: '2'
           - platform: windows
             cores: '8'
             runner: dsh-windows-2025-8core
+            blacksmith: blacksmith-8vcpu-windows-2025
             workers: '2'
           - platform: windows
             cores: '16'
             runner: dsh-windows-2025-16core
+            blacksmith: blacksmith-16vcpu-windows-2025
             workers: '2'
           - platform: windows
             cores: '32'
             runner: dsh-windows-2025-32core
+            blacksmith: blacksmith-32vcpu-windows-2025
             workers: '2'
           - platform: windows
             cores: '64'
             runner: dsh-windows-2025-64core
+            blacksmith: ''
             workers: '2'
           - platform: windows
             cores: '96'
             runner: dsh-windows-2025-96core
+            blacksmith: ''
             workers: '2'
     steps:
       - uses: actions/checkout@v6

+ 29 - 12
.github/workflows/ci.yml

@@ -31,7 +31,10 @@ jobs:
   # repository state — not PR-editable, no merge required) retargets all
   # three onto the in-house
   # vm-backup pool and re-running the failed jobs is the entire switch —
-  # see .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md. The
+  # see .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md.
+  # Setting the variable to 'blacksmith' instead routes the same jobs onto
+  # Blacksmith's hosted runners at the matching vCPU size (see
+  # .agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md). The
   # in-house pool's readiness is re-proven on every master push by the
   # serial-linux-selfhosted standby lane in ci-master.yml. The Windows failover
   # switch is the separate DSH_CI_FAILOVER_WINDOWS variable on the windows-native
@@ -39,7 +42,8 @@ jobs:
   node-24:
     if: github.event_name == 'pull_request'
     runs-on: >-
-      ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted'
+      ${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-16vcpu-ubuntu-2404'
+          || vars.DSH_CI_FAILOVER_LINUX == 'selfhosted'
           && github.event.pull_request.user.login != 'dependabot[bot]'
           && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]')
           || 'dsh-ubuntu-24-04-16core' }}
@@ -105,7 +109,8 @@ jobs:
   node-24-coverage:
     if: github.event_name == 'pull_request'
     runs-on: >-
-      ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted'
+      ${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-16vcpu-ubuntu-2404'
+          || vars.DSH_CI_FAILOVER_LINUX == 'selfhosted'
           && github.event.pull_request.user.login != 'dependabot[bot]'
           && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]')
           || 'dsh-ubuntu-24-04-16core' }}
@@ -226,7 +231,8 @@ jobs:
   node-24-consumers:
     if: github.event_name == 'pull_request'
     runs-on: >-
-      ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted'
+      ${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-16vcpu-ubuntu-2404'
+          || vars.DSH_CI_FAILOVER_LINUX == 'selfhosted'
           && github.event.pull_request.user.login != 'dependabot[bot]'
           && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]')
           || 'dsh-ubuntu-24-04-16core' }}
@@ -327,9 +333,12 @@ jobs:
 
   node-compat:
     if: github.event_name == 'pull_request'
-    # This job admits only repository-owned PR code to the persistent shared VM.
+    # Under the selfhosted leg this job admits only repository-owned PR code
+    # to the persistent shared VM; the blacksmith branch targets ephemeral
+    # runners and carries none of those predicates.
     runs-on: >-
-      ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted'
+      ${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-4vcpu-ubuntu-2404'
+          || vars.DSH_CI_FAILOVER_LINUX == 'selfhosted'
           && github.event.pull_request.head.repo.full_name == github.repository
           && github.event.pull_request.head.repo.fork == false
           && github.event.pull_request.user.login != 'dependabot[bot]'
@@ -451,7 +460,8 @@ jobs:
   windows-build:
     if: github.event_name == 'pull_request'
     runs-on: >-
-      ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted'
+      ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-16vcpu-windows-2025'
+          || vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted'
           && github.event.pull_request.user.login != 'dependabot[bot]'
           && fromJSON('["self-hosted", "dsh-win-ci", "windows"]')
           || 'dsh-windows-2025-16core' }}
@@ -495,7 +505,8 @@ jobs:
   windows-coverage:
     if: github.event_name == 'pull_request'
     runs-on: >-
-      ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted'
+      ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-16vcpu-windows-2025'
+          || vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted'
           && github.event.pull_request.user.login != 'dependabot[bot]'
           && fromJSON('["self-hosted", "dsh-win-ci", "windows"]')
           || 'dsh-windows-2025-16core' }}
@@ -571,7 +582,8 @@ jobs:
   windows-native-tests:
     if: github.event_name == 'pull_request'
     runs-on: >-
-      ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted'
+      ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-16vcpu-windows-2025'
+          || vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted'
           && github.event.pull_request.user.login != 'dependabot[bot]'
           && fromJSON('["self-hosted", "dsh-win-ci", "windows"]')
           || 'dsh-windows-2025-16core' }}
@@ -620,7 +632,8 @@ jobs:
     if: github.event_name == 'pull_request'
     continue-on-error: true
     runs-on: >-
-      ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted'
+      ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-16vcpu-windows-2025'
+          || vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted'
           && github.event.pull_request.user.login != 'dependabot[bot]'
           && fromJSON('["self-hosted", "dsh-win-ci", "windows"]')
           || 'dsh-windows-2025-16core' }}
@@ -675,9 +688,13 @@ jobs:
     # the worker jobs it aggregates, so a standard-hosted outage cannot strand
     # the branch-protection verdict either. It retargets with the Linux switch
     # (DSH_CI_FAILOVER_LINUX), not the Windows one, because it aggregates the
-    # required Linux workers and runs on the vm-backup pool.
+    # required Linux workers and runs on the vm-backup pool. Under the
+    # 'blacksmith' value the verdict shares Blacksmith's pool with its workers
+    # through the same selector, so a Blacksmith outage strands both together —
+    # the accepted consequence of the explicit opt-in switch.
     runs-on: >-
-      ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted'
+      ${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-4vcpu-ubuntu-2404'
+          || vars.DSH_CI_FAILOVER_LINUX == 'selfhosted'
           && github.event.pull_request.user.login != 'dependabot[bot]'
           && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]')
           || 'ubuntu-latest' }}

+ 3 - 1
.github/workflows/expected-filenames.yml

@@ -18,7 +18,9 @@ env:
 jobs:
   expected-filenames:
     name: no golden filenames
-    runs-on: ubuntu-latest
+    runs-on: >-
+      ${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-4vcpu-ubuntu-2404'
+          || 'ubuntu-latest' }}
     steps:
       - uses: actions/checkout@v6
 

+ 10 - 1
.github/workflows/sandbox.yml

@@ -44,6 +44,12 @@ jobs:
       fail-fast: false
       matrix:
         include:
+          # Only the bwrap leg has a Blacksmith equivalent: the earlier
+          # migration dispatch runs measured that Blacksmith's Linux images do
+          # not enforce Landlock (the run-guard would turn the leg red) and
+          # its macOS image is unverified, so those legs stay on GitHub's
+          # images under every failover value — see
+          # .agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md.
           - os: ubuntu-latest
             runner: bwrap
           - os: ubuntu-24.04
@@ -53,7 +59,10 @@ jobs:
           - os: macos-latest
             runner: seatbelt
     name: sandbox e2e (${{ matrix.runner }}, ${{ matrix.os }})
-    runs-on: ${{ matrix.os }}
+    runs-on: >-
+      ${{ matrix.runner == 'bwrap' && vars.DSH_CI_FAILOVER_LINUX == 'blacksmith'
+          && 'blacksmith-4vcpu-ubuntu-2404'
+          || matrix.os }}
     timeout-minutes: 20
     steps:
       - uses: actions/checkout@v6

+ 1 - 0
apps/cli/tests/web-agent-presets.e2e.ts

@@ -874,6 +874,7 @@ describe('authoring a preset on the shipped composition', () => {
  */
 describe('the default preset as a user setting', () => {
   it('composes an unnamed session from the stored default, not the composed one', async () => {
+    expect((await ctx.agentPresets.remoteExportList()).modeSelectionEnabled).toBe(true)
     expect(ctx.agentPresets.defaultId).toBe('standard')
 
     await ctx.settings.update(SETTINGS_NAMESPACE, { default: 'minimal' })

+ 7 - 1
apps/web/tests/agent-preset-authoring.e2e.ts

@@ -75,11 +75,15 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
     await dialog.waitFor({ timeout: 10_000 })
     await dialog.getByRole('button', { name: 'Agent 预设' }).click()
     await dialog.getByRole('heading', { name: 'Agent 预设' }).waitFor({ timeout: 10_000 })
-    await dialog.getByText('标准模式').first().waitFor({ timeout: 10_000 })
+    // The intro copy also names 标准模式. Wait for the roster's own action so
+    // the snapshot cannot land between the section shell and its cards.
+    await dialog.getByRole('button', { name: '查看: 标准模式', exact: true }).waitFor({ timeout: 10_000 })
 
     const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
 
     await compareOrRefreshGolden(SECTION_EXPECTED, snapshot, MODE)
+    const toggle = dialog.getByRole('switch', { name: '允许切换agent模式' })
+    expect(await toggle.getAttribute('aria-checked')).toBe('true')
     // The intro states the copy path directly, and the shipped rows offer
     // view/copy but never delete or a location — their
     // install is overwritten by upgrades and is not the user's to manage.
@@ -88,6 +92,8 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
     expect(snapshot).toContain('查看: 标准模式')
     expect(snapshot).not.toContain('删除: 标准模式')
     expect(snapshot).not.toContain('打开目录')
+    // The rest of this scenario exercises the existing default and Creator
+    // actions with the beta picker enabled by default.
   }, 60_000)
 
   it('views a shipped composition read-only instead of editing it', async () => {

+ 48 - 6
apps/web/tests/agent-preset-selection.e2e.ts

@@ -247,15 +247,21 @@ describe('web e2e: agent-preset selection', () => {
     await rm(presetRoot, { recursive: true, force: true })
   })
 
-  it('offers the chip on the new-session screen, beside the workspace picker', async () => {
+  it('starts with mode selection shown on the Standard default', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-hero'))
     await connectFreshWorkspace(page, scaffold.workspaceCwd)
+    await page.getByRole('button', { name: 'Standard mode', exact: true }).waitFor({ timeout: 10_000 })
 
-    const snapshot = await captureStableAria(page, '[class*="heroWorkspaceRow"]', scaffold.workspaceCwd)
+    await page.getByRole('button', { name: 'Settings', exact: true }).click()
+    const dialog = page.getByRole('dialog', { name: 'Settings' })
+    await dialog.getByRole('button', { name: 'Agent presets' }).click()
+    const toggle = dialog.getByRole('switch', { name: 'Allow switching Agent modes' })
+    await dialog.getByRole('button', { name: 'New task default: Standard mode' }).waitFor({ timeout: 10_000 })
+    expect(await toggle.getAttribute('aria-checked')).toBe('true')
+    await dialog.getByRole('button', { name: 'Close' }).last().click()
 
+    const snapshot = await captureStableAria(page, '[class*="heroWorkspaceRow"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(HERO_EXPECTED, snapshot, MODE)
-    // The chip opens on the deployment default, by the name that preset
-    // publishes rather than its directory name.
     expect(snapshot).toContain('Standard mode')
   })
 
@@ -283,6 +289,8 @@ describe('web e2e: agent-preset selection', () => {
     // The chip stages; the blank session the workspace connect produced is
     // what the stage lands on. The host's own answer is what comes back.
     await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('minimal')
+    const roster = await scaffold.ctx.agentPresets.remoteExportList()
+    expect(roster.presets.find(preset => preset.isDefault)?.id).toBe('standard')
   })
 
   it('says why a switch was refused instead of letting the chip revert in silence', async () => {
@@ -312,7 +320,8 @@ describe('web e2e: agent-preset selection', () => {
     await writeComposerDraft(page, composer, '/')
     await expect.poll(() => menuOptions(page), { timeout: 15_000 })
       .not.toEqual(expect.arrayContaining([expect.stringContaining(SKILL_NAME)]))
-    const onMinimal = await menuOptions(page)
+    // Rows read as `Title Description`; the title is the capitalized command name.
+    const onMinimal = (await menuOptions(page)).map(option => option.toLowerCase())
     expect(onMinimal.some(option => option.startsWith('compact'))).toBe(false)
     expect(onMinimal.some(option => option.startsWith('plan'))).toBe(false)
     // Preset-scoped commands follow the switch; the client's own model command
@@ -332,13 +341,46 @@ describe('web e2e: agent-preset selection', () => {
     await writeComposerDraft(page, composer, '/')
     await expect.poll(() => menuOptions(page), { timeout: 15_000 })
       .toEqual(expect.arrayContaining([expect.stringContaining(SKILL_NAME)]))
-    const onStandard = await menuOptions(page)
+    const onStandard = (await menuOptions(page)).map(option => option.toLowerCase())
     expect(onStandard.some(option => option.startsWith('compact'))).toBe(true)
     expect(onStandard.some(option => option.startsWith('goal'))).toBe(true)
     expect(onStandard.some(option => option.startsWith('plan'))).toBe(true)
     await writeComposerDraft(page, composer, '')
   }, 90_000)
 
+  it('aligns the current blank task and restores its saved default when re-enabled', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-disabled'))
+    await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('standard')
+
+    await page.getByRole('button', { name: 'Settings', exact: true }).click()
+    const dialog = page.getByRole('dialog', { name: 'Settings' })
+    await dialog.getByRole('button', { name: 'Agent presets' }).click()
+    await dialog.getByRole('button', { name: 'Set as default: Minimal mode' }).click()
+    await dialog.getByRole('button', { name: 'New task default: Minimal mode' }).waitFor({ timeout: 10_000 })
+    await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('minimal')
+    const toggle = dialog.getByRole('switch', { name: 'Allow switching Agent modes' })
+    await toggle.click()
+    await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false')
+    await dialog.getByRole('button', { name: 'Default: Standard mode' }).waitFor({ timeout: 10_000 })
+    await dialog.getByRole('button', { name: 'Close' }).last().click()
+
+    await expect.poll(() => page.getByRole('button', { name: / mode$/ }).count()).toBe(0)
+    await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('standard')
+
+    // The switch controls availability only: re-enabling restores the saved
+    // default and aligns this same still-blank task with it.
+    await page.getByRole('button', { name: 'Settings', exact: true }).click()
+    const reopened = page.getByRole('dialog', { name: 'Settings' })
+    await reopened.getByRole('button', { name: 'Agent presets' }).click()
+    const reopenedToggle = reopened.getByRole('switch', { name: 'Allow switching Agent modes' })
+    await reopenedToggle.click()
+    await expect.poll(() => reopenedToggle.getAttribute('aria-checked')).toBe('true')
+    await reopened.getByRole('button', { name: 'New task default: Minimal mode' }).waitFor({ timeout: 10_000 })
+    await reopened.getByRole('button', { name: 'Close' }).last().click()
+    await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('minimal')
+    await page.getByRole('button', { name: 'Minimal mode' }).waitFor({ timeout: 10_000 })
+  })
+
   it('labels a resumed session with the preset it was created under', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-header'))
     // The seeded session's cwd is the scaffold root rather than the connected

+ 5 - 2
apps/web/tests/expected/agent-preset-authoring/created.expected.md

@@ -19,11 +19,14 @@
     - text: 关闭
   - heading "Agent 预设" [level=2]
   - paragraph: 预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。
+  - text: 允许切换agent模式 beta
+  - paragraph: 开启后,新任务可选择标准、PTC、创造、极简及自定义模式;关闭后统一使用默认模式(默认为标准模式,可自定义)。仅影响新任务。
+  - switch "允许切换agent模式" [checked]
   - heading "内置" [level=3]
   - list:
     - listitem:
-      - 'button "当前使用: 标准模式" [disabled] [pressed]':
-        - text: 标准模式 内置 当前使用 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。
+      - 'button "新任务默认: 标准模式" [disabled] [pressed]':
+        - text: 标准模式 内置 新任务默认 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。
         - code: standard
       - 'button "查看: 标准模式"':
         - img

+ 5 - 2
apps/web/tests/expected/agent-preset-authoring/damaged.expected.md

@@ -19,11 +19,14 @@
     - text: 关闭
   - heading "Agent 预设" [level=2]
   - paragraph: 预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。
+  - text: 允许切换agent模式 beta
+  - paragraph: 开启后,新任务可选择标准、PTC、创造、极简及自定义模式;关闭后统一使用默认模式(默认为标准模式,可自定义)。仅影响新任务。
+  - switch "允许切换agent模式" [checked]
   - heading "内置" [level=3]
   - list:
     - listitem:
-      - 'button "当前使用: 标准模式" [disabled] [pressed]':
-        - text: 标准模式 内置 当前使用 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。
+      - 'button "新任务默认: 标准模式" [disabled] [pressed]':
+        - text: 标准模式 内置 新任务默认 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。
         - code: standard
       - 'button "查看: 标准模式"':
         - img

+ 5 - 2
apps/web/tests/expected/agent-preset-authoring/section.expected.md

@@ -19,11 +19,14 @@
     - text: 关闭
   - heading "Agent 预设" [level=2]
   - paragraph: 预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。
+  - text: 允许切换agent模式 beta
+  - paragraph: 开启后,新任务可选择标准、PTC、创造、极简及自定义模式;关闭后统一使用默认模式(默认为标准模式,可自定义)。仅影响新任务。
+  - switch "允许切换agent模式" [checked]
   - heading "内置" [level=3]
   - list:
     - listitem:
-      - 'button "当前使用: 标准模式" [disabled] [pressed]':
-        - text: 标准模式 内置 当前使用 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。
+      - 'button "新任务默认: 标准模式" [disabled] [pressed]':
+        - text: 标准模式 内置 新任务默认 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。
         - code: standard
       - 'button "查看: 标准模式"':
         - img

+ 2 - 4
apps/web/tests/expected/clickable-links-gallery/ui.expected.md

@@ -153,7 +153,7 @@
 - paragraph:
   - text: Wrote
   - code:
-    - button "Open site/report.html": report.html
+    - button "Open site/report.html in sidebar": report.html
   - text: plus two
   - code: style.css
   - text: copies;
@@ -192,9 +192,7 @@
 - button "Back to bottom":
   - img
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":

+ 1 - 3
apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md

@@ -50,9 +50,7 @@
   - text: Ran for {{duration}}
 - text: {{clock}}
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
 - 'button "Access mode, current: Read Only"': Read Only
 - button "Select model, current github-webhook-review-test/reply":

+ 1 - 3
apps/web/tests/expected/github-ready-review/conversation.expected.md

@@ -42,9 +42,7 @@
   - text: Ran for {{duration}}
 - text: {{clock}}
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
 - 'button "Access mode, current: Read Only"': Read Only
 - button "Select model, current github-webhook-review-test/reply":

+ 1 - 3
apps/web/tests/expected/goal-command-presentation/ui.expected.md

@@ -16,9 +16,7 @@
   - img
   - text: "goal No goal is currently set. Usage: /goal [<objective>|clear|edit <objective>|pause|resume]"
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":

+ 1 - 3
apps/web/tests/expected/markdown-cjk-strong/ui.expected.md

@@ -50,9 +50,7 @@
   - text: Ran for {{duration}}
 - text: {{clock}}
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":

+ 1 - 3
apps/web/tests/expected/markdown-images/ui.expected.md

@@ -36,9 +36,7 @@
   - text: Ran for {{duration}}
 - text: {{clock}}
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":

+ 1 - 3
apps/web/tests/expected/markdown-inline-code-links/ui.expected.md

@@ -41,9 +41,7 @@
   - text: Ran for {{duration}}
 - text: {{clock}}
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":

+ 1 - 3
apps/web/tests/expected/math-rendering/ui.expected.md

@@ -45,9 +45,7 @@
   - text: Ran for {{duration}}
 - text: {{clock}}
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":

+ 1 - 3
apps/web/tests/expected/reference-composer/order.expected.md

@@ -15,9 +15,7 @@
   - img
   - text: Session recall Research notes
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":

+ 3 - 0
apps/web/tests/expected/skill-invocation-policy/preview.expected.md

@@ -0,0 +1,3 @@
+- separator
+- 'heading "name: policy-shared description: Available to both model and user invocation" [level=2]'
+- heading "policy-shared" [level=1]

+ 7 - 5
apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md

@@ -1,6 +1,6 @@
 - banner:
   - navigation "Session hierarchy":
-    - button "/user-invoke-demo and confirm the fixtur" [disabled]
+    - button "/user-invoke-demo @\"meeting notes.md\" an" [disabled]
   - img
   - text: Standard mode
   - button "More actions":
@@ -14,7 +14,11 @@
   - img
   - img
   - text: System prompt
-- text: /user-invoke-demo and confirm the fixture wiring {{clock}}
+- button "/user-invoke-demo"
+- button "meeting notes.md":
+  - img
+  - text: meeting notes.md
+- text: and confirm the fixture wiring {{clock}}
 - button "Copy":
   - img
 - button "Thought for a while" [expanded]:
@@ -42,9 +46,7 @@
   - text: Ran for {{duration}}
 - text: {{clock}}
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":

+ 7 - 5
apps/web/tests/expected/skill-user-invoke/ui.expected.md

@@ -1,6 +1,6 @@
 - banner:
   - navigation "Session hierarchy":
-    - button "/user-invoke-demo and confirm the fixtur" [disabled]
+    - button "/user-invoke-demo @\"meeting notes.md\" an" [disabled]
   - img
   - text: Standard mode
   - button "More actions":
@@ -14,7 +14,11 @@
   - img
   - img
   - text: System prompt
-- text: /user-invoke-demo and confirm the fixture wiring {{clock}}
+- button "/user-invoke-demo"
+- button "meeting notes.md":
+  - img
+  - text: meeting notes.md
+- text: and confirm the fixture wiring {{clock}}
 - button "Copy":
   - img
 - button "Thought for a while":
@@ -34,9 +38,7 @@
   - text: Ran for {{duration}}
 - text: {{clock}}
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":

+ 1 - 3
apps/web/tests/expected/stats-paged-history/ui.expected.md

@@ -461,9 +461,7 @@
 - button "Back to bottom":
   - img
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":

+ 1 - 3
apps/web/tests/expected/steer-all/mid-steer.expected.md

@@ -33,9 +33,7 @@
 - button "Copy":
   - img
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":

+ 1 - 3
apps/web/tests/expected/steer-all/settled-expanded.expected.md

@@ -52,9 +52,7 @@
   - text: Ran for {{duration}}
 - text: {{clock}}
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":

+ 1 - 3
apps/web/tests/expected/steer-all/settled.expected.md

@@ -40,9 +40,7 @@
   - text: Ran for {{duration}}
 - text: {{clock}}
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":

+ 1 - 1
apps/web/tests/feedback-command.e2e.ts

@@ -82,7 +82,7 @@ describe('web e2e: /feedback command acknowledgement', () => {
     await page.getByText(/Feedback recorded for session/).waitFor({ timeout: 10_000 })
     expect(await page.getByText(/Anonymous user: [0-9a-f-]+\.$/i).count()).toBe(1)
     await expect.poll(() => input.textContent(), { timeout: 10_000 }).toBe('')
-    await expect.poll(() => page.getByRole('button', { name: 'Add attachment' }).isEnabled(), { timeout: 10_000 }).toBe(true)
+    await expect.poll(() => page.getByRole('button', { name: 'Add files or run commands' }).isEnabled(), { timeout: 10_000 }).toBe(true)
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(ACK_EXPECTED, snapshot, MODE)
     const expanded = await captureExpandedTurnProcessAria(

+ 1 - 1
apps/web/tests/goal-command-presentation.e2e.ts

@@ -51,7 +51,7 @@ describe('web e2e: /goal human transcript presentation', () => {
     const input = page.locator('[data-composer-input]').first()
     await input.fill('/go')
     const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
-    await menu.getByRole('option', { name: 'goal set or view the goal for a long-running task' })
+    await menu.getByRole('option', { name: 'Goal Set or view the goal for a long-running task' })
       .waitFor({ timeout: 10_000 })
     await input.press('Tab')
     await expect.poll(() => input.textContent()).toBe('/goal ')

+ 68 - 5
apps/web/tests/lifecycle-chrome.e2e.ts

@@ -78,13 +78,14 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
       await writeComposerDraft(page, input, '')
       await page.getByRole('listbox', { name: 'Trigger suggestions' }).waitFor({ state: 'hidden' })
     })
-    const launcher = page.getByRole('button', { name: 'Commands' })
+    const launcher = page.getByRole('button', { name: 'Add files or run commands' })
     await launcher.click()
     const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
     await menu.getByRole('option').first().waitFor({ timeout: 10_000 })
     await menu.getByRole('status').waitFor({ state: 'hidden', timeout: 10_000 })
     const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(COMMAND_MENU_EXPECTED, snapshot, MODE)
+    expect(snapshot).toContain('text: Add')
     expect(snapshot).toContain('text: Commands')
     expect(snapshot).not.toContain('text: Skills')
     expect(snapshot).not.toContain('text: Subagents')
@@ -103,7 +104,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
     )).toBeLessThan(1)
     await writeComposerDraft(page, input, '/cpt')
     await expect.poll(() => menu.getByRole('option').allTextContents()).toEqual([
-      'compactCompact older conversation history',
+      'CompactCompact older conversation history',
     ])
     const fuzzySnapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(FUZZY_COMMAND_MENU_EXPECTED, fuzzySnapshot, MODE)
@@ -118,7 +119,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
     try {
       await zhPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
       await zhPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
-      const launcher = zhPage.getByRole('button', { name: '指令' })
+      const launcher = zhPage.getByRole('button', { name: '添加文件或调用指令' })
       await launcher.click()
       const menu = zhPage.getByRole('listbox', { name: '触发候选建议' })
       await menu.getByRole('option').first().waitFor({ timeout: 10_000 })
@@ -132,6 +133,68 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
     }
   })
 
+  it.skipIf(MODE === 'record').each([
+    { locale: 'en-US', token: '/goal', row: 'Goal Set or view the goal for a long-running task', hint: 'describe the objective for a long-running task' },
+    { locale: 'en-US', token: '/plan', row: 'Plan Enter or leave plan mode', hint: 'describe your task to generate plan' },
+    { locale: ZH_BROWSER_LOCALE, token: '/目标', row: '目标 goal 设置或查看长期任务目标', hint: '输入目标,智能体将持续执行' },
+    { locale: ZH_BROWSER_LOCALE, token: '/计划', row: '计划 plan 进入或退出计划模式', hint: '描述你的任务以生成计划' },
+  ])('keeps $token claimed across separator edits and hides hints during IME composition', async ({ locale, token, row, hint }) => {
+    const inputPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale })
+    const inputTripwire = watchConsole(inputPage)
+    onTestFailed(() => saveFailureShot(inputPage, `web-e2e-command-input-${locale}-${token.slice(1)}`))
+    try {
+      await inputPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+      await inputPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+      const input = inputPage.locator('[data-composer-input]').first()
+      await writeComposerDraft(inputPage, input, '/')
+      await inputPage.getByRole('listbox').getByRole('option', { name: row, exact: true }).click()
+      await expect.poll(() => input.textContent()).toBe(`${token} `)
+      await input.press('End')
+      await inputPage.keyboard.insertText('这是任务')
+      await expect.poll(() => input.textContent()).toBe(`${token} 这是任务`)
+      for (let i = 0; i < 5; i++) await input.press('Backspace')
+      const tokenText = () => input.locator('[data-lexical-text][style*="warn-label"]').textContent()
+      await expect.poll(() => input.textContent()).toBe(token)
+      await expect.poll(() => input.getAttribute('data-phase')).toBe('claimed')
+      await expect.poll(tokenText).toBe(token)
+      await input.press('Space')
+      await expect.poll(() => input.getAttribute('data-phase')).toBe('claimed')
+      await expect.poll(() => input.textContent()).toBe(`${token} `)
+      await expect.poll(async () => (await tokenText())?.trimEnd()).toBe(token)
+      const shownHint = () => input.locator('p').last().evaluate(element => getComputedStyle(element, '::after').content)
+      await expect.poll(shownHint).toBe(JSON.stringify(hint))
+      const cdp = await inputPage.context().newCDPSession(inputPage)
+      await cdp.send('Input.imeSetComposition', { text: 'z', selectionStart: 1, selectionEnd: 1 })
+      await expect.poll(shownHint).toBe('none')
+      await cdp.send('Input.imeSetComposition', { text: 'zh', selectionStart: 2, selectionEnd: 2 })
+      await expect.poll(shownHint).toBe('none')
+      await cdp.send('Input.insertText', { text: '这' })
+      await expect.poll(() => input.textContent()).toBe(`${token} 这`)
+      await expect.poll(shownHint).toBe('none')
+      await input.press('Backspace')
+      await expect.poll(shownHint).toBe(JSON.stringify(hint))
+      await cdp.send('Input.imeSetComposition', { text: 'z', selectionStart: 1, selectionEnd: 1 })
+      await expect.poll(shownHint).toBe('none')
+      await cdp.send('Input.imeSetComposition', { text: '', selectionStart: 0, selectionEnd: 0 })
+      await expect.poll(shownHint).toBe(JSON.stringify(hint))
+      await input.press('Backspace')
+      await input.press('Backspace')
+      await expect.poll(() => input.getAttribute('data-phase')).toBe('plain')
+      await writeComposerDraft(inputPage, input, '')
+      const placeholder = inputPage.locator('[data-composer-placeholder]').first()
+      await expect.poll(() => placeholder.isVisible()).toBe(true)
+      await cdp.send('Input.imeSetComposition', { text: 'z', selectionStart: 1, selectionEnd: 1 })
+      await expect.poll(() => placeholder.isVisible()).toBe(false)
+      await cdp.send('Input.imeSetComposition', { text: '', selectionStart: 0, selectionEnd: 0 })
+      await expect.poll(() => placeholder.isVisible()).toBe(true)
+      await cdp.detach()
+      expect(inputTripwire.pageErrors).toEqual([])
+      expect(inputTripwire.warnings).toEqual([])
+    } finally {
+      await inputPage.close()
+    }
+  })
+
   it.skipIf(MODE === 'record')('shows active Plan as the warn-state status action', async () => {
     const activeScaffold = await launchWebScaffold()
     const activePage = await newEnglishPage(browser)
@@ -141,10 +204,10 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
       await activePage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
       await connectFreshWorkspace(activePage, activeScaffold.workspaceCwd)
       const input = activePage.locator('[data-composer-input]').first()
-      await activePage.getByRole('button', { name: 'Commands' }).click()
+      await activePage.getByRole('button', { name: 'Add files or run commands' }).click()
       const menu = activePage.getByRole('listbox', { name: 'Trigger suggestions' })
       await menu.waitFor({ timeout: 10_000 })
-      await menu.getByRole('option', { name: 'plan Enter or leave plan mode' }).click()
+      await menu.getByRole('option', { name: 'Plan Enter or leave plan mode' }).click()
       await expect.poll(() => input.textContent()).toBe('/plan ')
       await input.press('Enter')
       const planButton = activePage.getByRole('button', { name: 'Plan mode on, press to turn off' })

+ 1 - 1
apps/web/tests/navigation-panes.e2e.ts

@@ -335,7 +335,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
       const input = page.locator('[data-composer-input]').first()
       const slashDownloadPromise = page.waitForEvent('download', { timeout: 30_000 })
       await input.fill('/export')
-      await page.getByRole('option', { name: /export/u }).waitFor({ timeout: 10_000 })
+      await page.getByRole('option', { name: /^Export/u }).waitFor({ timeout: 10_000 })
       await input.press('Enter')
       const slashDownload = await slashDownloadPromise
       expect(slashDownload.suggestedFilename()).toBe(download.suggestedFilename())

+ 15 - 7
apps/web/tests/present.e2e.ts

@@ -123,6 +123,21 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con
       await row.waitFor()
       expect(await row.getByRole('button', { name: /More file actions/ }).count()).toBe(2)
       expect(await row.getByText('report.txt', { exact: true }).innerText()).toBe('report.txt')
+      const beforePreview = (await opened()).length
+      const column = page.locator('[data-rightbar-col]')
+      for (const [name, content] of [['report.txt', 'EDITED_REPORT'], ['说明.txt', 'EDITED_NOTE']] as const) {
+        const mention = page.locator('code').getByRole('button', { name: `Open ${name} in sidebar`, exact: true })
+        await mention.click()
+        const preview = column.locator('[data-document-preview]')
+        await expect.poll(() => preview.getAttribute('data-textpreview-url'))
+          .toBe(`dsh-resource://file/session/${sessionId}/${encodeURIComponent(name)}`)
+        await preview.getByText(content, { exact: true }).waitFor()
+        await mention.click()
+        expect(await column.locator('[data-dockkit-tab]').filter({ hasText: name }).count()).toBe(1)
+      }
+      expect(await opened()).toHaveLength(beforePreview)
+      expect(downloads).toEqual([])
+      await page.getByRole('button', { name: 'Collapse right sidebar', exact: true }).click()
       const beforeReveal = (await opened()).length
       await row.getByRole('button', { name: 'More file actions for report.txt', exact: true }).click()
       const revealResponse = page.waitForResponse(response => response.url().includes('action=reveal') && response.request().method() === 'POST')
@@ -143,13 +158,6 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con
         expect((await opened()).at(-1)).toEqual({ action: 'open', path: await realpath(join(cwd, name)), content: bytes })
       }
     }
-    const count = (await opened()).length
-    const openedResponse = page.waitForResponse(response => response.url().includes('/api/present.open?') && response.request().method() === 'POST')
-    await page.locator('code').getByRole('button', { name: 'Open report.txt in default app', exact: true }).click()
-    await page.waitForFunction(() => document.querySelector('[data-presented-files-row] button:disabled') === null)
-    expect((await openedResponse).status()).toBe(204)
-    expect(await opened()).toHaveLength(count + 1)
-    expect((await opened()).at(-1)).toEqual({ action: 'open', path: await realpath(join(cwd, 'report.txt')), content: 'EDITED_REPORT\n' })
     expect(downloads).toEqual([])
     const response = await page.request.get(new URL(`/api/session.export?sessionId=${sessionId}`, scaffold.authenticatedUrl).href)
     expect(response.status()).toBe(200)

+ 2 - 9
apps/web/tests/produced-file-mentions.e2e.ts

@@ -1,11 +1,4 @@
-// Web e2e scenario: inline-code file mentions in the closing prose. Cold-seeds
-// a built write turn (zero model calls) whose closing message names the written
-// file three ways: by unique basename (links), ambiguously (stays inert), and
-// as a file the turn never touched (stays inert). Package tests cover the
-// resolver in isolation; only the assembled application shows a real write's
-// locations reaching the prose as an opener. The click itself is not driven
-// here: it hands the path to the Host's opener, which would launch a real
-// application on the machine running the suite (the produced-files restraint).
+/** Web e2e coverage for unique, ambiguous, and unknown inline file references. */
 import type { Browser, Page } from 'playwright'
 import { chromium } from 'playwright'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
@@ -156,7 +149,7 @@ describe('web e2e: inline-code mentions of produced files', () => {
     const mentions = page.locator('[class*="markdown"] code button')
     await expect.poll(() => mentions.count(), { timeout: 10_000 }).toBe(1)
     expect(await mentions.first().innerText()).toBe('report.html')
-    expect(await mentions.first().getAttribute('aria-label')).toBe('Open site/report.html')
+    expect(await mentions.first().getAttribute('aria-label')).toBe('Open site/report.html in sidebar')
     expect(await mentions.first().getAttribute('title')).toBe('site/report.html')
     // The turn still ends with its produced-files row (all three writes).
     expect(await page.getByText('Files changed', { exact: true }).count()).toBe(1)

+ 1 - 1
apps/web/tests/seeded-history.e2e.ts

@@ -506,7 +506,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
 
       // command/done can arrive before the submit reply releases the composer.
       await expect.poll(() => input.textContent(), { timeout: 10_000 }).toBe('')
-      await expect.poll(() => page.getByRole('button', { name: 'Add attachment' }).isEnabled(), { timeout: 10_000 }).toBe(true)
+      await expect.poll(() => page.getByRole('button', { name: 'Add files or run commands' }).isEnabled(), { timeout: 10_000 }).toBe(true)
       const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
         .split(SEED_ID).join('{{seededId}}')
         .split(userId).join('{{userId}}')

+ 3 - 0
apps/web/tests/settings-chrome.e2e.ts

@@ -293,6 +293,9 @@ describe('web e2e: settings modal and General preferences', () => {
     await page.getByRole('button', { name: '设置', exact: true }).click()
     const restoredDialog = page.getByRole('dialog', { name: '设置' })
     const systemCube = restoredDialog.getByRole('button', { name: '跟随系统' })
+    // The boot palette precedes the settings mirror's saved preference.
+    const restoredDarkCube = restoredDialog.getByRole('button', { name: '深色' })
+    await expect.poll(() => restoredDarkCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true')
     await selectTheme(systemCube, 'system')
     await expect.poll(() => systemCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true')
     await expect.poll(() => page.evaluate(() => document.body.hasAttribute('data-ds-dark-theme')), {

+ 2 - 1
apps/web/tests/shipped-composition.e2e.ts

@@ -183,8 +183,9 @@ it('assembles the shipped Web transport, catalog, guidance, and defaults', async
   })
   try {
     expect(scaffold.ctx.commands.list(commandHandle.agent)).toContainEqual({
+      definitionId: '@deepseek-ai/dsh-command-feedback',
       name: 'feedback',
-      description: 'record feedback about this session',
+      description: 'Record feedback about this session',
       input: { hint: '<text>' },
     })
   } finally {

+ 1 - 1
apps/web/tests/sidebar-scrollbar.e2e.ts

@@ -83,7 +83,7 @@ function measureList(page: Page): Promise<ListMetrics> {
       })
       .filter((rule): rule is CSSStyleRule => rule instanceof CSSStyleRule)
       .filter(rule => rule.selectorText === '::-webkit-scrollbar-thumb:hover')
-      .map(rule => rule.style.getPropertyValue('background'))
+      .map(rule => rule.style.getPropertyValue('background-color'))
     const style = getComputedStyle(list)
     const pseudoWidth = getComputedStyle(list, '::-webkit-scrollbar').width
     const barWidth = pseudoWidth === 'auto' ? 15 : Number.parseFloat(pseudoWidth)

+ 80 - 3
apps/web/tests/skill-invocation-policy.e2e.ts

@@ -3,7 +3,7 @@
 // with their marker while user-disabled quadrants stay hidden. A real
 // chromium connects a fresh workspace seeded with all four policy quadrants;
 // no model call is issued, so a stray stream fails loud on the open LLM seam.
-import { mkdir, writeFile } from 'node:fs/promises'
+import { mkdir, symlink, writeFile } from 'node:fs/promises'
 import { fileURLToPath } from 'node:url'
 import { join } from 'node:path'
 import type { Browser, Page } from 'playwright'
@@ -56,7 +56,8 @@ const SKILLS: readonly SeedSkill[] = [
 
 async function seedSkills(workspaceCwd: string): Promise<void> {
   for (const skill of SKILLS) {
-    const directory = join(workspaceCwd, 'workspace', '.agents', 'skills', skill.name)
+    const root = join(workspaceCwd, 'workspace', '.agents', 'skills')
+    const directory = skill.name === 'policy-shared' ? join(workspaceCwd, 'linked-skills', skill.name) : join(root, skill.name)
     await mkdir(directory, { recursive: true })
     const policyLines = skill.frontmatter === '' ? [] : skill.frontmatter.trimEnd().split('\n')
     await writeFile(join(directory, 'SKILL.md'), [
@@ -69,6 +70,10 @@ async function seedSkills(workspaceCwd: string): Promise<void> {
       `# ${skill.name}`,
       '',
     ].join('\n'))
+    if (skill.name === 'policy-shared') {
+      await mkdir(root, { recursive: true })
+      await symlink(join(directory, 'SKILL.md'), join(root, `${skill.name}.md`))
+    }
   }
 }
 
@@ -81,6 +86,7 @@ describe('web e2e: skill invocation policy through the real host', () => {
   beforeAll(async () => {
     scaffold = await launchWebScaffold({})
     await seedSkills(scaffold.workspaceCwd)
+    await writeFile(join(scaffold.workspaceCwd, 'workspace', 'meeting-notes.md'), '# Meeting notes\n\nReference preview fixture.\n')
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
@@ -122,6 +128,77 @@ describe('web e2e: skill invocation policy through the real host', () => {
     await compareOrRefreshGolden(FUZZY_MENU_EXPECTED, fuzzySnapshot, MODE)
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
-    await assertFixtureInventory(SNAPSHOT_DIR, ['menu-fuzzy.expected.md', 'menu.expected.md'])
+    await assertFixtureInventory(SNAPSHOT_DIR, ['menu-fuzzy.expected.md', 'menu.expected.md', 'preview.expected.md'])
+  })
+
+  it('opens skill and file references beside the unchanged draft with matching hover backgrounds', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-reference-preview'))
+    const input = page.locator('[data-composer-input]').first()
+    await writeComposerDraft(page, input, '/policy-shared hello @meeting-notes')
+    const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
+    const option = menu.getByRole('option', { name: /meeting-notes\.md/ })
+    await option.click()
+    const skill = input.locator('[data-composer-text-ref]').filter({ hasText: '/policy-shared' })
+    const file = input.locator('[data-composer-chip]')
+    const draft = await input.textContent()
+    const alignment = await input.evaluate((el) => {
+      const skill = el.querySelector<HTMLElement>('[data-composer-text-ref]')!
+      const chip = el.querySelector<HTMLElement>('[data-composer-chip] span')!
+      const plain = el.querySelector<HTMLElement>('[data-lexical-text]:not([data-composer-text-ref])')!
+      const textTop = (element: Element): number => {
+        const range = el.ownerDocument.createRange()
+        range.selectNodeContents(element)
+        return range.getBoundingClientRect().top
+      }
+      return {
+        skillTop: skill.getBoundingClientRect().top,
+        fileTop: chip.getBoundingClientRect().top,
+        skillHeight: skill.getBoundingClientRect().height,
+        fileHeight: chip.getBoundingClientRect().height,
+        skillTextTop: textTop(skill),
+        fileTextTop: textTop(chip.lastElementChild!),
+        plainTextTop: textTop(plain),
+      }
+    })
+    expect(alignment.fileHeight).toBeCloseTo(alignment.skillHeight, 0)
+    expect(alignment.fileTop).toBeCloseTo(alignment.skillTop, 0)
+    expect(alignment.fileTextTop).toBeCloseTo(alignment.plainTextTop, 0)
+    expect(alignment.skillTextTop).toBeCloseTo(alignment.plainTextTop, 0)
+    await skill.hover()
+    const skillBackground = await skill.evaluate(el => getComputedStyle(el).backgroundColor)
+    expect(skillBackground).not.toBe('rgba(0, 0, 0, 0)')
+    await skill.click()
+    const preview = page.locator('[data-document-markdown]')
+    await expect.poll(() => preview.textContent()).toContain('policy-shared')
+    expect(await input.textContent()).toBe(draft)
+    const snapshot = await captureStableAria(page, '[data-document-markdown]', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'preview.expected.md'), snapshot, MODE)
+    await file.hover()
+    expect(await file.locator('span').first().evaluate(el => getComputedStyle(el).backgroundColor)).toBe(skillBackground)
+    await file.click()
+    await expect.poll(() => preview.textContent()).toContain('Reference preview fixture.')
+    expect(await input.textContent()).toBe(draft)
+    await file.hover()
+    await skill.dblclick()
+    await expect.poll(() => preview.textContent()).toContain('policy-shared')
+    await expect.poll(() => page.evaluate(() => document.getSelection()?.toString())).not.toBe('')
+    expect(await input.textContent()).toBe(draft)
+    await skill.hover()
+    await expect.poll(() => skill.evaluate(el => getComputedStyle(el).backgroundColor)).toBe(skillBackground)
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+    // Establish the deletion caret before the key event; Chromium delivers
+    // native selectionchange asynchronously after pointer and arrow actions.
+    await input.evaluate((el) => {
+      el.focus()
+      const selection = el.ownerDocument.getSelection()!
+      selection.selectAllChildren(el)
+      selection.collapseToEnd()
+      el.ownerDocument.dispatchEvent(new Event('selectionchange'))
+    })
+    await page.keyboard.press('Backspace')
+    await page.keyboard.press('Backspace')
+    await expect.poll(() => input.locator('[data-composer-chip]').count()).toBe(0)
+    expect(await input.textContent()).toContain('/policy-shared')
   })
 })

+ 24 - 1
apps/web/tests/skill-user-invoke.e2e.ts

@@ -30,7 +30,7 @@ const UI_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'ui-expanded.expected.md')
 const MODE = webSnapshotMode()
 
 const SKILL_NAME = 'user-invoke-demo'
-const ARGS_TEXT = 'and confirm the fixture wiring'
+const ARGS_TEXT = '@"meeting notes.md" and confirm the fixture wiring'
 const REPLY = 'USER_INVOKE_REPLY acknowledged; following the injected skill.'
 
 async function seedUserOnlySkill(workspaceCwd: string): Promise<void> {
@@ -78,6 +78,7 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro
       paceMs: 10,
     })
     await seedUserOnlySkill(scaffold.workspaceCwd)
+    await writeFile(join(scaffold.workspaceCwd, 'workspace', 'meeting notes.md'), '# Meeting notes\n\nSent reference preview.\n')
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
@@ -159,6 +160,28 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro
     expect(tripwire.warnings).toEqual([])
   }, 60_000)
 
+  it('previews sent skill and quoted file references with prose-link hover styling after reloading history', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-sent-reference-preview'))
+    await page.reload({ waitUntil: 'load' })
+    const skill = page.locator('[data-chat-flow-kind="user"] [data-ref-chip="skill"]').first()
+    await skill.waitFor({ timeout: 15_000 })
+    const preview = page.locator('[data-document-markdown]')
+    await skill.hover()
+    await expect.poll(() => skill.evaluate(el => getComputedStyle(el).textDecorationStyle)).toBe('dotted')
+    await skill.click()
+    await expect.poll(() => preview.textContent(), { timeout: 10_000 }).toContain('Reply with the fixture acknowledgement line.')
+    const file = page.locator('[data-chat-flow-kind="user"] [data-ref-chip="file"]').first()
+    await file.hover()
+    expect(await file.evaluate(el => getComputedStyle(el).textDecorationStyle)).toBe('dotted')
+    await file.click()
+    await expect.poll(() => preview.textContent()).toContain('Sent reference preview.')
+    await skill.click()
+    await expect.poll(() => preview.textContent()).toContain('Reply with the fixture acknowledgement line.')
+    expect(await page.locator('[data-chat-flow-kind="user"]').first().textContent()).toContain('and confirm the fixture wiring')
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+  })
+
   it('keeps its snapshot inventory closed', async () => {
     await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md', 'ui-expanded.expected.md'])
   })

+ 1 - 3
apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md

@@ -26,9 +26,7 @@
 - code: "const first: number = 1 const second = \"two\" let tail"
 - status: Deep diving...
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current streaming-fence-highlight-test/streaming-fence":

+ 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: 07788bc02bc0abc049a72cab978e1565a5be0453
-config-catalog.zh.md: 58229a8415a6a76d3fad160bf9c06a47d8f84a33
+config-catalog.md: d10b95a3a124e8dfee7cb35d6ae8fab25e20f29b
+config-catalog.zh.md: 24915a5f1d4f29b43be718915a0d10d33fb327f3

+ 4 - 4
docs/config-catalog.md

@@ -1644,7 +1644,7 @@ export interface PresetSpec {
 
 Depends on: [`ApprovalPolicy`](subsystems/approval.md) · [`SandboxMode`](subsystems/sandbox.md)
 
-Source: [`packages/interaction/permission-presets/src/index.ts:143`](../packages/interaction/permission-presets/src/index.ts)
+Source: [`packages/interaction/permission-presets/src/index.ts:144`](../packages/interaction/permission-presets/src/index.ts)
 
 <a id="deepseek-aidsh-persona"></a>
 
@@ -1689,7 +1689,7 @@ export interface PlanModeConfig {
 }
 ```
 
-Source: [`packages/plan/plan-mode/src/index.ts:63`](../packages/plan/plan-mode/src/index.ts)
+Source: [`packages/plan/plan-mode/src/index.ts:64`](../packages/plan/plan-mode/src/index.ts)
 
 <a id="deepseek-aidsh-plugin-package-inventory-deepseek"></a>
 
@@ -1930,7 +1930,7 @@ export interface Config {
 export type SessionLogCompressionLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
 ```
 
-Source: [`packages/session-query/session-log-export/src/index.ts:45`](../packages/session-query/session-log-export/src/index.ts)
+Source: [`packages/session-query/session-log-export/src/index.ts:46`](../packages/session-query/session-log-export/src/index.ts)
 
 <a id="deepseek-aidsh-session-persistence-jsonl"></a>
 
@@ -2192,7 +2192,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/skill/skill/src/index.ts:280`](../packages/skill/skill/src/index.ts)
+Source: [`packages/skill/skill/src/index.ts:278`](../packages/skill/skill/src/index.ts)
 
 <a id="deepseek-aidsh-skill-filesystem"></a>
 

+ 4 - 4
docs/config-catalog.zh.md

@@ -1646,7 +1646,7 @@ export interface PresetSpec {
 
 依赖:[`ApprovalPolicy`](subsystems/approval.zh.md) · [`SandboxMode`](subsystems/sandbox.zh.md)
 
-来源:[`packages/interaction/permission-presets/src/index.ts:143`](../packages/interaction/permission-presets/src/index.ts)
+来源:[`packages/interaction/permission-presets/src/index.ts:144`](../packages/interaction/permission-presets/src/index.ts)
 
 <a id="deepseek-aidsh-persona"></a>
 
@@ -1691,7 +1691,7 @@ export interface PlanModeConfig {
 }
 ```
 
-来源:[`packages/plan/plan-mode/src/index.ts:63`](../packages/plan/plan-mode/src/index.ts)
+来源:[`packages/plan/plan-mode/src/index.ts:64`](../packages/plan/plan-mode/src/index.ts)
 
 <a id="deepseek-aidsh-plugin-package-inventory-deepseek"></a>
 
@@ -1932,7 +1932,7 @@ export interface Config {
 export type SessionLogCompressionLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
 ```
 
-来源:[`packages/session-query/session-log-export/src/index.ts:45`](../packages/session-query/session-log-export/src/index.ts)
+来源:[`packages/session-query/session-log-export/src/index.ts:46`](../packages/session-query/session-log-export/src/index.ts)
 
 <a id="deepseek-aidsh-session-persistence-jsonl"></a>
 
@@ -2194,7 +2194,7 @@ export interface Config {
 }
 ```
 
-来源:[`packages/skill/skill/src/index.ts:280`](../packages/skill/skill/src/index.ts)
+来源:[`packages/skill/skill/src/index.ts:278`](../packages/skill/skill/src/index.ts)
 
 <a id="deepseek-aidsh-skill-filesystem"></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: 72c0b97c56f0c4a1ffe4c22116cc8a8beff24aed
-event-producer-consumer.zh.md: aaa5602c363743a53de7100a8f029ad65a9d3f94
+event-producer-consumer.md: 280a68df728374f2d65634b5711ae0f025d8a4c6
+event-producer-consumer.zh.md: aac8158bad38eca367170f8dc455c26fca649656

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

@@ -8,7 +8,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | Event | Mode | Declared in | Dispatchers | Listeners |
 | --- | --- | --- | --- | --- |
 | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:246`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
-| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:80`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` |
+| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:82`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` |
 | `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:373`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
 | `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:258`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
 | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:267`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
@@ -22,14 +22,14 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:316`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
 | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:277`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
 | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:391`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
-| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:599`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:606`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:585`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:592`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:601`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:581`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:608`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:587`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:594`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
 | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
 | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
-| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:87`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
+| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
 | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:380`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
 | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:386`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
 | `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:392`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
@@ -54,7 +54,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
 | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` |
 | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
-| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:298`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
+| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
 | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:170`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) |
 | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:144`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
 | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:150`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |

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

@@ -24,14 +24,14 @@
 | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:316`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
 | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:277`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
 | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:391`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
-| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:599`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:606`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:585`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:592`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:601`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:581`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:608`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:587`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:594`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
 | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
 | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
-| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:87`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
+| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
 | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:380`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
 | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:386`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
 | `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:392`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
@@ -56,7 +56,7 @@
 | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
 | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` |
 | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
-| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:298`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
+| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
 | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:168`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) |
 | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:142`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
 | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:148`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |

+ 2 - 2
docs/persistence-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/persistence-catalog.md
-persistence-catalog.md: 8621cd16ad625e0c73f56faf197aa095f1fa8410
-persistence-catalog.zh.md: 99589be298ea3e42285fa85476c28effcd476a6c
+persistence-catalog.md: 69982dcf3cb162bc95d7b9f1c3bc2375977ca33d
+persistence-catalog.zh.md: efb8b5b53b6d2c8ffdd96b3bc2e9c2f797926d7d

+ 4 - 4
docs/persistence-catalog.md

@@ -263,7 +263,7 @@ Source: [`packages/core/session/src/types.ts:330`](../packages/core/session/src/
 }
 ```
 
-Source: [`packages/interaction/commands/src/types.ts:110`](../packages/interaction/commands/src/types.ts)
+Source: [`packages/interaction/commands/src/types.ts:112`](../packages/interaction/commands/src/types.ts)
 
 <a id="commandrun--log-only"></a>
 
@@ -283,7 +283,7 @@ Source: [`packages/interaction/commands/src/types.ts:110`](../packages/interacti
 'command/run': { commandId: CommandId; name: string; args?: string; source: CommandSource }
 ```
 
-Source: [`packages/interaction/commands/src/types.ts:103`](../packages/interaction/commands/src/types.ts)
+Source: [`packages/interaction/commands/src/types.ts:105`](../packages/interaction/commands/src/types.ts)
 
 ### `compaction/*`
 
@@ -590,7 +590,7 @@ Source: [`packages/api/session-controller/src/types.ts:40`](../packages/api/sess
 'permission/preset': { preset: string }
 ```
 
-Source: [`packages/interaction/permission-presets/src/index.ts:53`](../packages/interaction/permission-presets/src/index.ts)
+Source: [`packages/interaction/permission-presets/src/index.ts:54`](../packages/interaction/permission-presets/src/index.ts)
 
 ### `plan/*`
 
@@ -607,7 +607,7 @@ Source: [`packages/interaction/permission-presets/src/index.ts:53`](../packages/
 'plan/mode': { active: boolean }
 ```
 
-Source: [`packages/plan/plan-mode/src/index.ts:46`](../packages/plan/plan-mode/src/index.ts)
+Source: [`packages/plan/plan-mode/src/index.ts:47`](../packages/plan/plan-mode/src/index.ts)
 
 ### `request/*`
 

+ 4 - 4
docs/persistence-catalog.zh.md

@@ -265,7 +265,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 }
 ```
 
-来源:[`packages/interaction/commands/src/types.ts:110`](../packages/interaction/commands/src/types.ts)
+来源:[`packages/interaction/commands/src/types.ts:112`](../packages/interaction/commands/src/types.ts)
 
 <a id="commandrun--log-only"></a>
 
@@ -285,7 +285,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 'command/run': { commandId: CommandId; name: string; args?: string; source: CommandSource }
 ```
 
-来源:[`packages/interaction/commands/src/types.ts:103`](../packages/interaction/commands/src/types.ts)
+来源:[`packages/interaction/commands/src/types.ts:105`](../packages/interaction/commands/src/types.ts)
 
 ### `compaction/*`
 
@@ -592,7 +592,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 'permission/preset': { preset: string }
 ```
 
-来源:[`packages/interaction/permission-presets/src/index.ts:53`](../packages/interaction/permission-presets/src/index.ts)
+来源:[`packages/interaction/permission-presets/src/index.ts:54`](../packages/interaction/permission-presets/src/index.ts)
 
 ### `plan/*`
 
@@ -609,7 +609,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 'plan/mode': { active: boolean }
 ```
 
-来源:[`packages/plan/plan-mode/src/index.ts:46`](../packages/plan/plan-mode/src/index.ts)
+来源:[`packages/plan/plan-mode/src/index.ts:47`](../packages/plan/plan-mode/src/index.ts)
 
 ### `request/*`
 

Неке датотеке нису приказане због велике количине промена