Przeglądaj źródła

Merge master native containment updates into feat/electron

07akioni 2 tygodni temu
rodzic
commit
8d0d729091
100 zmienionych plików z 2258 dodań i 250 usunięć
  1. 2 2
      .agents/notes/implemented/architecture/2026-06-13-capability-seams.i18n.yaml
  2. 1 1
      .agents/notes/implemented/architecture/2026-06-13-capability-seams.md
  3. 1 1
      .agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml
  5. 7 7
      .agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md
  6. 7 7
      .agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md
  7. 2 2
      .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml
  8. 0 0
      .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md
  9. 0 0
      .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md
  10. 2 2
      .agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml
  11. 1 1
      .agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md
  12. 1 1
      .agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md
  13. 2 2
      .agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.i18n.yaml
  14. 3 3
      .agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md
  15. 3 3
      .agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.zh.md
  16. 2 2
      .agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.i18n.yaml
  17. 1 1
      .agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md
  18. 1 1
      .agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md
  19. 2 2
      .agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml
  20. 1 1
      .agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md
  21. 1 1
      .agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md
  22. 6 0
      .agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml
  23. 83 0
      .agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md
  24. 83 0
      .agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md
  25. 2 2
      .agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml
  26. 3 3
      .agents/notes/implemented/feature/2026-07-06-sandbox.md
  27. 3 3
      .agents/notes/implemented/feature/2026-07-06-sandbox.zh.md
  28. 2 2
      .agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml
  29. 6 4
      .agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md
  30. 6 4
      .agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md
  31. 6 0
      .agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.i18n.yaml
  32. 57 0
      .agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.md
  33. 57 0
      .agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.zh.md
  34. 6 0
      .agents/notes/implemented/feature/2026-09-07-model-switch-notice.i18n.yaml
  35. 29 0
      .agents/notes/implemented/feature/2026-09-07-model-switch-notice.md
  36. 29 0
      .agents/notes/implemented/feature/2026-09-07-model-switch-notice.zh.md
  37. 34 24
      apps/cli/src/bin.ts
  38. 20 1
      apps/cli/tests/built-bin.e2e.ts
  39. 3 0
      apps/cli/tests/web-agent-presets.e2e.ts
  40. 1 1
      apps/cli/tsdown.config.ts
  41. 1 1
      apps/web/tests/hmr-live.e2e.ts
  42. 5 1
      apps/web/tests/preview-boot.e2e.ts
  43. 7 0
      apps/web/tests/scaffold.ts
  44. 2 2
      docs/config-catalog.i18n.yaml
  45. 36 4
      docs/config-catalog.md
  46. 36 4
      docs/config-catalog.zh.md
  47. 2 2
      docs/event-producer-consumer.i18n.yaml
  48. 1 1
      docs/event-producer-consumer.md
  49. 1 1
      docs/event-producer-consumer.zh.md
  50. 2 2
      docs/module-graph.i18n.yaml
  51. 4 0
      docs/module-graph.md
  52. 4 0
      docs/module-graph.zh.md
  53. 2 2
      docs/subsystems/shell.i18n.yaml
  54. 8 5
      docs/subsystems/shell.md
  55. 8 5
      docs/subsystems/shell.zh.md
  56. 2 2
      docs/subsystems/subprocess.i18n.yaml
  57. 25 26
      docs/subsystems/subprocess.md
  58. 25 26
      docs/subsystems/subprocess.zh.md
  59. 1 0
      package.json
  60. 13 0
      packages/bundle/web-app/cordis.patch.yml
  61. 2 0
      packages/bundle/web-app/package.json
  62. 2 2
      packages/client/README.i18n.yaml
  63. 1 0
      packages/client/README.md
  64. 1 0
      packages/client/README.zh.md
  65. 1 1
      packages/client/tsdown.client.ts
  66. 6 0
      packages/client/ui-open-in-app/README.i18n.yaml
  67. 83 0
      packages/client/ui-open-in-app/README.md
  68. 83 0
      packages/client/ui-open-in-app/README.zh.md
  69. 68 0
      packages/client/ui-open-in-app/package.json
  70. 65 0
      packages/client/ui-open-in-app/src/client/OpenInAppAction.module.css
  71. 228 0
      packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx
  72. 87 0
      packages/client/ui-open-in-app/src/client/controller.ts
  73. 53 0
      packages/client/ui-open-in-app/src/client/index.ts
  74. 69 0
      packages/client/ui-open-in-app/src/client/locales.ts
  75. 6 0
      packages/client/ui-open-in-app/src/css-modules.d.ts
  76. 10 0
      packages/client/ui-open-in-app/src/index.ts
  77. 125 0
      packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts
  78. 84 0
      packages/client/ui-open-in-app/tests/controller.client.spec.ts
  79. 243 0
      packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx
  80. 42 0
      packages/client/ui-open-in-app/tsconfig.json
  81. 3 0
      packages/client/ui-open-in-app/tsdown.config.ts
  82. 5 0
      packages/client/ui-primitives/src/Menu.module.css
  83. 9 4
      packages/client/ui-primitives/src/Menu.tsx
  84. 18 0
      packages/client/ui-primitives/tests/atoms.client.spec.tsx
  85. 2 2
      packages/core/agent/README.i18n.yaml
  86. 5 5
      packages/core/agent/README.md
  87. 5 5
      packages/core/agent/README.zh.md
  88. 54 2
      packages/core/agent/src/model-selection.ts
  89. 133 3
      packages/core/agent/tests/model-selection.spec.ts
  90. 2 2
      packages/e2b/subprocess-e2b/README.i18n.yaml
  91. 4 5
      packages/e2b/subprocess-e2b/README.md
  92. 4 5
      packages/e2b/subprocess-e2b/README.zh.md
  93. 24 1
      packages/e2b/subprocess-e2b/src/index.ts
  94. 8 13
      packages/e2b/subprocess-e2b/src/process.ts
  95. 42 15
      packages/e2b/subprocess-e2b/tests/subprocess.spec.ts
  96. 1 0
      packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
  97. 4 3
      packages/extensions/tool-cordis/src/api-catalog.ts
  98. 5 4
      packages/fs/tool-fs-search/src/search-core.ts
  99. 3 2
      packages/fs/tool-fs-search/tests/integration.spec.ts
  100. 5 6
      packages/fs/tool-fs-search/tests/tools.spec.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-06-13-capability-seams.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-06-13-capability-seams.md
-2026-06-13-capability-seams.md: 46a2c39e927e859c7eb95956d8586f3bf04c7b1c
-2026-06-13-capability-seams.zh.md: f44e3e68d2153149435b0fd0aaa5fd121cf3ecad
+2026-06-13-capability-seams.md: 3c552c474b499b9f1c9f60242f4773750faadafb
+2026-06-13-capability-seams.zh.md: 4e6100a2ce557d91fa18b5630c267f37f1bc0f00

+ 1 - 1
.agents/notes/implemented/architecture/2026-06-13-capability-seams.md

@@ -15,7 +15,7 @@ This is distinct from "who provides vs. needs a capability at runtime", which Co
 A swappable capability has **three roles**:
 
 1. **Service Definition** — the Cordis `Service` and vocabulary types owning `ctx.<key>` and depending only on the vocabulary the contract needs (e.g. `dsh-shell`: `ShellExecutor`, `ShellRunResult`, `ShellProcess`). A definition may be an abstract class or a concrete registry service; it is never a TypeScript `interface`.
-2. **Service Provider** — a plugin that supplies or registers an implementation (e.g. `dsh-bash-local`: subprocesses, process-group kills, spill-file truncation). Sandboxed and remote providers are sibling packages implementing or registering against the same Service Definition.
+2. **Service Provider** — a plugin that supplies or registers an implementation (e.g. `dsh-bash-local`: subprocesses, provider-managed range termination, spill-file truncation). The [native-containment decision](2026-08-28-subprocess-native-containment.md) owns the local provider's OS-specific range mechanics. Sandboxed and remote providers are sibling packages implementing or registering against the same Service Definition.
 3. **Consumer** — what the model and plugins program against (e.g. `dsh-tool-bash`: the `bash` schema, with background handles registered into the generic job runtime). Consumers inject the service key and never import provider-specific types.
 
 The role names use title case: **Service Definition**, **Service Provider**, and **Consumer**. Generic uses of `provider` and `consumer` remain lowercase.

+ 1 - 1
.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md

@@ -15,7 +15,7 @@ harness 具有可替换的能力,包括 shell 执行和模型提供方。一
 一项可替换的能力包含**三个角色**:
 
 1. **Service Definition**——拥有 `ctx.<key>` 的 Cordis `Service` 和词汇类型,仅依赖约定所需的词汇(例如 `dsh-shell`:`ShellExecutor`、`ShellRunResult`、`ShellProcess`)。Service Definition 可以是抽象类,也可以是具体的注册表服务;绝不是 TypeScript `interface`。
-2. **Service Provider**——提供或注册实现的插件(例如 `dsh-bash-local`:子进程、进程组 kill、spill 文件截断)。沙箱化和远程 Service Provider 是依据同一 Service Definition 实现或注册的兄弟包。
+2. **Service Provider**——提供或注册实现的插件(例如 `dsh-bash-local`:子进程、由提供方管理的范围终止、spill 文件截断)。[原生 containment 决策](2026-08-28-subprocess-native-containment.zh.md)负责本地提供方的 OS 特有范围机制。沙箱化和远程 Service Provider 是依据同一 Service Definition 实现或注册的兄弟包。
 3. **Consumer**——模型和插件编程所面向的内容(例如 `dsh-tool-bash`:`bash` schema,后台句柄注册到通用任务运行时)。Consumer 注入服务键,从不导入 Service Provider 特有的类型。
 
 角色名使用标题式大小写:**Service Definition**、**Service Provider** 和 **Consumer**。泛指的 `provider` 和 `consumer` 仍使用小写。

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md
-2026-07-06-timeout-deadline-library.md: 24dcabfd039e553af28fd3cc1d2c7f717ee66509
-2026-07-06-timeout-deadline-library.zh.md: 5f31cf21606345bb323ce3f839b5cb939def5a48
+2026-07-06-timeout-deadline-library.md: 6f726938731f18386105ff1180c79fef981c95bf
+2026-07-06-timeout-deadline-library.zh.md: 48bd7b4b91efd0045c03e3e1e1e9e37656b4dcf6

+ 7 - 7
.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md

@@ -8,11 +8,11 @@ English | [中文](2026-07-06-timeout-deadline-library.zh.md)
 
 Timeout handling was drifting apart across the tool-bearing capabilities, and the divergence was not superficial — it was the same logic re-implemented three ways, each with its own subtle correctness burden.
 
-- **bash** (then in the bash-local implementation's `run.ts`) had a full, correct timeout inside the process plumbing: a config-clamped `timeoutMs`, two independent triggers — a `killTimer` for the timeout and an `onAbort` listener for upstream cancellation — each calling one `kill()` closure that escalates SIGTERM→grace→SIGKILL on the process group, and two orthogonal outcome booleans (`timedOut`, `aborted`) latched independently. After this consolidation, the plumbing — [packages/subprocess/subprocess-local/src/spawn.ts](../../../../packages/subprocess/subprocess-local/src/spawn.ts) — only reacts to aborts; [packages/shell/bash-local/src/index.ts](../../../../packages/shell/bash-local/src/index.ts) owns the fused deadline and the `timedOut`/`aborted` classification.
+- **bash** (then in the bash-local implementation's `run.ts`) had a full, correct timeout inside the process plumbing: a config-clamped `timeoutMs`, two independent triggers — a `killTimer` for the timeout and an `onAbort` listener for upstream cancellation — each calling one `kill()` closure that drove the subprocess termination path, and two orthogonal outcome booleans (`timedOut`, `aborted`) latched independently. After this consolidation, the plumbing — [packages/subprocess/subprocess-local/src/spawn.ts](../../../../packages/subprocess/subprocess-local/src/spawn.ts) — only reacts to aborts; [packages/shell/bash-local/src/index.ts](../../../../packages/shell/bash-local/src/index.ts) owns the fused deadline and the `timedOut`/`aborted` classification.
 - **web_fetch** ([packages/web/web-fetch-http/src/provider.ts](../../../../packages/web/web-fetch-http/src/provider.ts)) had a correct but *hand-rolled* timeout: it constructed an `AbortController`, wired `setTimeout(() => controller.abort(new WebError(…, 'WEB_FETCH_TIMEOUT')))`, manually added and removed the upstream-signal listener, cleared the timer in a `finally`, and recovered the timeout reason from `signal.reason` in a `translateAbortOrNetwork` helper because the reader surfaces a bare `AbortError`.
 - **web_search** ([packages/web/tool-web/src/search.ts](../../../../packages/web/tool-web/src/search.ts)) had **no timeout at all**: `WebSearchRequest` ([packages/web/web/src/types.ts](../../../../packages/web/web/src/types.ts)) carries no `timeoutMs` field, and each provider's `search()` only forwards `exec.signal`. (web_search stays untimed here — see Consequences.)
 
-Each new external-process or network tool re-derived the same four things — clamp the requested value, start a timer, fuse the timeout with upstream cancellation, and distinguish "timed out" from "cancelled" on the way out — and the fusion and reason-recovery are exactly the parts that are easy to get subtly wrong (web_fetch's `signal.reason` dance is evidence). At the same time, the *termination* each performs is irreducibly different: bash kills an OS process group (work runs in a child process, outside this runtime, reachable only by signal), while web aborts an in-process `fetch` (undici tears down the socket). There is no single mechanism that can stop all of them.
+Each new external-process or network tool re-derived the same four things — clamp the requested value, start a timer, fuse the timeout with upstream cancellation, and distinguish "timed out" from "cancelled" on the way out — and the fusion and reason-recovery are exactly the parts that are easy to get subtly wrong (web_fetch's `signal.reason` dance is evidence). At the same time, the *termination* each performs is irreducibly different: bash asks its subprocess provider to terminate an OS-owned range, while web aborts an in-process `fetch` and lets undici tear down the socket. The [native-containment decision](2026-08-28-subprocess-native-containment.md) owns the local scope, Job, and fallback mechanisms; there is no single mechanism that can stop every capability's work.
 
 ## Decision
 
@@ -87,17 +87,17 @@ export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string):
 | The default/max *values* | the capability's config |
 | The timeout `code` string | the capability (`WEB_FETCH_TIMEOUT` ≠ `BASH_TIMEOUT`) |
 
-The signal only *notifies*; termination is always the listener's job, and the listener differs by capability. bash writes its own `addEventListener('abort', kill)` because the OS process lives outside this runtime and nothing else will kill it; web hands `d.signal` to `fetch` and undici tears down the socket. This is why file read/write/edit take **no** `timeoutMs`: a local syscall is best-effort-abortable at most, a timeout could not force `fsync`/`rename` to stop, and adding one would be an implicit default that violates explicit-over-implicit. Both reference agents leave file I/O untimed for the same reason.
+The signal only *notifies*; termination is always the listener's job, and the listener differs by capability. bash writes its own `addEventListener('abort', kill)` because the OS process lives outside this runtime and its subprocess provider must drive the owned range to settlement; web hands `d.signal` to `fetch` and undici tears down the socket. This is why file read/write/edit take **no** `timeoutMs`: a local syscall is best-effort-abortable at most, a timeout could not force `fsync`/`rename` to stop, and adding one would be an implicit default that violates explicit-over-implicit. Both reference agents leave file I/O untimed for the same reason.
 
 ### How each capability consumes it
 
 - **web_fetch** — the tool stays validate-and-forward; the provider's hand-rolled controller + `setTimeout` + manual listener + `finally` + `signal.reason` recovery is replaced by provider-owned `deadline`/`timeoutOf`. A pre-aborted upstream signal still throws `WEB_ABORTED` up front; otherwise `fetch` runs against the fused `d.signal`, and `translateAbortOrNetwork` classifies a thrown error by the signal (`timeoutOf` → `WEB_FETCH_TIMEOUT`, else aborted → `WEB_ABORTED`, else network → `WEB_PROVIDER_ERROR`). The public error-code contract is unchanged, and `TimeoutReason` never crosses the web seam as the public error.
-- **bash** — `resolve()` clamps the request into an explicit spec. Foreground `run()` creates the deadline and passes its signal to process execution, whose existing abort listener performs the process-group kill. The executor classifies the first abort as timeout or cancellation. Background starts remain timeout-free and forward only upstream cancellation.
+- **bash** — `resolve()` clamps the request into an explicit spec. Foreground `run()` creates the deadline and passes its signal to process execution, whose abort listener calls `SubprocessHandle.terminate()` and awaits the same provider-managed range. The executor classifies the first abort as timeout or cancellation. Background starts remain timeout-free and forward only upstream cancellation.
 - **LLM adapters** — `dsh-llm-deepseek` and `dsh-llm-pi-ai` wrap actual transport iteration with `idleWatchdog`. The five-minute configured interval covers only outstanding provider demand, not time the downstream consumer spends between chunks. The direct DeepSeek adapter also pulses that outstanding demand when its SSE parser observes a comment, without yielding the comment as a `StreamChunk` or writing it to the session log. The pi-ai SDK does not expose comment activity to its adapter, so that path can rearm only when the SDK yields. The stable signal reaches `fetch` or the SDK for the whole call, so timeout closes the underlying request and maps to `TIMEOUT`, while an earlier caller abort maps to `ABORTED`.
 
 ## Consequences
 
-- `runBash`'s outcome no longer independently latches `timedOut` and `aborted`; a timeout and a user abort racing before process close now report a single first-abort cause instead of both being true. The uniform SIGTERM→grace→SIGKILL kill is unchanged, and the Service Definition type `ShellRunResult` keeps both booleans (now mutually exclusive), so `dsh-tool-bash`'s result rendering is untouched.
+- `runBash`'s outcome no longer independently latches `timedOut` and `aborted`; a timeout and a user abort racing before process close now report a single first-abort cause instead of both being true. Timeout classification does not alter provider-owned termination: local POSIX ranges use TERM→grace→KILL, while Windows ordinary ranges terminate immediately. The Service Definition type `ShellRunResult` keeps both booleans (now mutually exclusive), so `dsh-tool-bash`'s result rendering is untouched.
 - `SpawnSpec.timeoutMs` and `SpawnOutcome.timedOut`/`aborted` were removed rather than kept as always-zero/always-false vestiges: with `runBash` owning no timer and the executor owning classification, they were read nowhere. An always-0 field read by nothing is dead weight under the per-file coverage gate.
 - web_fetch shed its bespoke controller/timer/listener/reason-recovery; the classifier now keys off the deadline signal (`timeoutOf` + `aborted`) rather than the thrown error's shape, which is robust across both the request-phase reject-with-reason and the read-phase bare-`AbortError`.
 - `AbortSignal.any` and `using`/`Symbol.dispose` enter the repo for the first time here (Node ≥ 24 baseline, already met).
@@ -107,10 +107,10 @@ Out of scope, named to mark the boundary: `web_search` can gain an optional mode
 
 ## Alternatives considered
 
-**A unified timeout *plugin* / `ctx.timeout` service.** Rejected on microkernel grounds. A service that could stop any tool's work would have to understand every capability's termination mechanism (process-group SIGKILL, socket teardown, syscall-boundary checks) — the "kernel knows too much" the architecture forbids. Codex's `ExecExpiration` is scoped to the exec family precisely because the kill it drives (`killpg`) is process-family-specific; MCP and model-stream keep their own. There is no coherent middle layer that owns termination for everything, so the shared piece can only be the pure timing/classification half — a library, not a service.
+**A unified timeout *plugin* / `ctx.timeout` service.** Rejected on microkernel grounds. A service that could stop any tool's work would have to understand every capability's termination mechanism (native scope or Job termination, fallback process-group signalling, socket teardown, syscall-boundary checks) — the "kernel knows too much" the architecture forbids. Codex's `ExecExpiration` is scoped to the exec family precisely because the kill it drives (`killpg`) is process-family-specific; MCP and model-stream keep their own. There is no coherent middle layer that owns termination for everything, so the shared piece can only be the pure timing/classification half — a library, not a service.
 
 **Per-tool ad-hoc timeout, no shared code (the prior status quo, and Claude Code's choice).** Rejected because it was already producing divergence and duplicated correctness burden: web_fetch hand-rolled the exact controller/reason logic that future network/process-backed tools would each have to re-derive, and the fusion + `signal.reason` recovery are the error-prone parts. Claude Code tolerates full duplication; this repo has a single shared abort channel (`exec.signal` on every `execute`) that makes a small shared primitive strictly cleaner, so the cost/benefit differs.
 
 **A `withTimeout(promise, ms)` wrapper instead of a signal factory.** Rejected because racing a promise against a timer resolves the *tool-call* promise on deadline without stopping the underlying work — the child process or fetch socket leaks on. Handing out a signal and requiring the capability to listen is what forces a real termination path to exist. This mirrors the "dispose must reach quiescence, not just request it" defensive rule.
 
-**Keep separate bash timeout and cancellation triggers.** Rejected because one deadline signal removes the bespoke timer and standardizes classification. Racing causes report whichever abort arrived first, while the existing SIGTERM-to-SIGKILL termination path remains unchanged.
+**Keep separate bash timeout and cancellation triggers.** Rejected because one deadline signal removes the bespoke timer and standardizes classification. Racing causes report whichever abort arrived first, while the provider-owned termination path is independent of which cause won.

+ 7 - 7
.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md

@@ -8,11 +8,11 @@ Status: implemented
 
 超时处理在各个承载工具的能力之间逐渐分化,而且这种分化并非表面的:同一套逻辑被以三种方式重新实现,各自带有微妙的正确性负担。
 
-- **bash**(当时位于 bash-local 实现的 `run.ts`)在进程管道内部有一套完整、正确的超时实现:一个经配置钳位的 `timeoutMs`,两个独立触发器(用于超时的 `killTimer` 和用于上游取消的 `onAbort` 监听器),各自调用同一个 `kill()` 闭包对进程组执行 SIGTERM→宽限期→SIGKILL 升级,以及两个正交的结果布尔值(`timedOut`、`aborted`)独立锁存。经此次整合之后,这套管道——位于 [packages/subprocess/subprocess-local/src/spawn.ts](../../../../packages/subprocess/subprocess-local/src/spawn.ts)——只响应中止;[packages/shell/bash-local/src/index.ts](../../../../packages/shell/bash-local/src/index.ts) 拥有融合的 deadline 以及 `timedOut`/`aborted` 分类。
+- **bash**(当时位于 bash-local 实现的 `run.ts`)在进程管道内部有一套完整、正确的超时实现:一个经配置钳位的 `timeoutMs`,两个独立触发器(用于超时的 `killTimer` 和用于上游取消的 `onAbort` 监听器),各自调用同一个 `kill()` 闭包驱动子进程终止路径,以及两个正交的结果布尔值(`timedOut`、`aborted`)独立锁存。经此次整合之后,这套管道——位于 [packages/subprocess/subprocess-local/src/spawn.ts](../../../../packages/subprocess/subprocess-local/src/spawn.ts)——只响应中止;[packages/shell/bash-local/src/index.ts](../../../../packages/shell/bash-local/src/index.ts) 拥有融合的 deadline 以及 `timedOut`/`aborted` 分类。
 - **web_fetch**([packages/web/web-fetch-http/src/provider.ts](../../../../packages/web/web-fetch-http/src/provider.ts))有一套正确但*手写*的超时:构造一个 `AbortController`,连接 `setTimeout(() => controller.abort(new WebError(…, 'WEB_FETCH_TIMEOUT')))`,手动添加和移除上游信号监听器,在 `finally` 中清除定时器,并在 `translateAbortOrNetwork` 辅助函数中从 `signal.reason` 恢复超时原因(因为 reader 只抛出裸 `AbortError`)。
 - **web_search**([packages/web/tool-web/src/search.ts](../../../../packages/web/tool-web/src/search.ts))**完全没有超时**:`WebSearchRequest`([packages/web/web/src/types.ts](../../../../packages/web/web/src/types.ts))不携带 `timeoutMs` 字段,各提供方的 `search()` 只转发 `exec.signal`。(web_search 在本次设计中保持无超时——见「后果」。)
 
-每个新的外部进程或网络工具都要重新推导同样四件事:钳位请求值、启动定时器、将超时与上游取消融合、在出口处区分「超时」与「已取消」。而融合与原因恢复恰恰是最容易出微妙错误的部分(web_fetch 的 `signal.reason` 处理就是证据)。与此同时,各能力执行的*终止*操作不可归约地不同:bash 杀死一个 OS 进程组(工作运行在子进程中,在本运行时之外,只能通过信号触达),而 web 中止一个进程内的 `fetch`(undici 拆除 socket)。不存在一个能停止所有能力工作的单一机制。
+每个新的外部进程或网络工具都要重新推导同样四件事:钳位请求值、启动定时器、将超时与上游取消融合、在出口处区分「超时」与「已取消」。而融合与原因恢复恰恰是最容易出微妙错误的部分(web_fetch 的 `signal.reason` 处理就是证据)。与此同时,各能力执行的*终止*操作不可归约地不同:bash 请求其子进程提供方终止由 OS 拥有的范围,而 web 中止一个进程内的 `fetch`,由 undici 拆除 socket。[原生 containment 决策](2026-08-28-subprocess-native-containment.zh.md)负责本地 scope、Job 与 fallback 机制;不存在一个能停止所有能力工作的单一机制。
 
 ## 决策
 
@@ -87,17 +87,17 @@ export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string):
 | 默认值/最大值*数值* | 各能力的配置 |
 | 超时 `code` 字符串 | 各能力(`WEB_FETCH_TIMEOUT` ≠ `BASH_TIMEOUT`) |
 
-信号只*通知*;终止始终是监听方的职责,而监听方因能力而异。bash 自行编写 `addEventListener('abort', kill)`,因为 OS 进程存在于本运行时之外,没有别的东西会杀死它;web 将 `d.signal` 交给 `fetch`,由 undici 拆除 socket。这也是文件读/写/编辑**不接受** `timeoutMs` 的原因:本地系统调用最多只能尽力中止,超时无法强制 `fsync`/`rename` 停止,添加超时将是一个违反「显式优于隐式」的隐式默认值。两个参考 agent(智能体)出于同样的原因对文件 I/O 不设超时。
+信号只*通知*;终止始终是监听方的职责,而监听方因能力而异。bash 自行编写 `addEventListener('abort', kill)`,因为 OS 进程存在于本运行时之外,必须由子进程提供方驱动其拥有的范围达到完全停稳;web 将 `d.signal` 交给 `fetch`,由 undici 拆除 socket。这也是文件读/写/编辑**不接受** `timeoutMs` 的原因:本地系统调用最多只能尽力中止,超时无法强制 `fsync`/`rename` 停止,添加超时将是一个违反「显式优于隐式」的隐式默认值。两个参考 agent(智能体)出于同样的原因对文件 I/O 不设超时。
 
 ### 各能力如何消费该库
 
 - **web_fetch**:工具层保持校验并转发;提供方手写的 controller + `setTimeout` + 手动监听器 + `finally` + `signal.reason` 恢复被替换为提供方自有的 `deadline`/`timeoutOf`。已预先中止的上游信号仍然立即抛出 `WEB_ABORTED`;否则 `fetch` 使用融合后的 `d.signal` 运行,`translateAbortOrNetwork` 根据信号分类抛出的错误(`timeoutOf` → `WEB_FETCH_TIMEOUT`,否则已中止 → `WEB_ABORTED`,否则网络错误 → `WEB_PROVIDER_ERROR`)。公开的错误码约定不变,`TimeoutReason` 永远不会作为公开错误跨越 web seam。
-- **bash**:`resolve()` 将请求钳位为显式规格。前台 `run()` 创建 deadline 并将其信号传给进程执行,后者既有的 abort 监听器执行进程组 kill。执行器将首个 abort 分类为超时或取消。后台启动保持无超时,仅转发上游取消。
+- **bash**:`resolve()` 将请求钳位为显式规格。前台 `run()` 创建 deadline 并将其信号传给进程执行,后者的 abort 监听器调用 `SubprocessHandle.terminate()`,并等待同一个由提供方管理的范围。执行器将首个 abort 分类为超时或取消。后台启动保持无超时,仅转发上游取消。
 - **LLM(大语言模型)适配器**:`dsh-llm-deepseek` 和 `dsh-llm-pi-ai` 用 `idleWatchdog` 包装实际的传输迭代。配置的五分钟间隔只覆盖尚未结算的提供方 demand,不包括下游消费方在分片之间花费的时间。DeepSeek 直连适配器还会在其 SSE(Server-Sent Events)解析器观察到注释时,对该项尚未结算的 demand 调用 `pulse()`;该注释既不会作为 `StreamChunk` 产出,也不会写入会话日志。pi-ai SDK 不会向其适配器暴露注释活动,因此该路径只能在 SDK 产出值时重新启动定时器。稳定信号在整个调用期间传给 `fetch` 或 SDK,因此超时会关闭底层请求并映射为 `TIMEOUT`,而更早的调用方中止映射为 `ABORTED`。
 
 ## 后果
 
-- `runBash` 的结果不再独立锁存 `timedOut` 和 `aborted`;超时与用户中止在进程关闭前竞争时,现在报告单一的首个 abort 原因,而非两者同时为 true。统一的 SIGTERM→宽限期→SIGKILL 终止路径不变,Service Definition 类型 `ShellRunResult` 保留两个布尔值(现在互斥),因此 `dsh-tool-bash` 的结果渲染不受影响。
+- `runBash` 的结果不再独立锁存 `timedOut` 和 `aborted`;超时与用户中止在进程关闭前竞争时,现在报告单一的首个 abort 原因,而非两者同时为 true。超时分类不改变由提供方管理的终止:本地 POSIX 范围使用 TERM→宽限期→KILL,Windows 普通范围则立即终止。Service Definition 类型 `ShellRunResult` 保留两个布尔值(现在互斥),因此 `dsh-tool-bash` 的结果渲染不受影响。
 - `SpawnSpec.timeoutMs` 和 `SpawnOutcome.timedOut`/`aborted` 被移除,而非作为始终为零/始终为 false 的残余保留:由于 `runBash` 不再拥有定时器且执行器负责分类,这些字段无处被读取。一个始终为 0 且无处读取的字段在逐文件覆盖率门禁下属于死代码。
 - web_fetch 去除了其定制的 controller/timer/listener/reason-recovery;分类器现在基于 deadline 信号(`timeoutOf` + `aborted`)而非抛出错误的形状来判断,这在请求阶段的 reject-with-reason 和读取阶段的裸 `AbortError` 两种情况下都是健壮的。
 - `AbortSignal.any` 和 `using`/`Symbol.dispose` 在此首次进入本仓库(Node ≥ 24 基线,已满足)。
@@ -107,10 +107,10 @@ export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string):
 
 ## 曾考虑的替代方案
 
-**统一的超时*插件* / `ctx.timeout` 服务。** 基于微内核原则否决。一个能停止任何工具工作的服务必须理解每个能力的终止机制(进程组 SIGKILL、socket 拆除、系统调用边界检查),这正是架构所禁止的「内核知道太多」。Codex 的 `ExecExpiration` 被限定于 exec 族,正是因为它驱动的 kill(`killpg`)是进程族特有的;MCP 和模型流各自保有自己的。不存在一个连贯的中间层能为所有东西拥有终止权,因此共享部分只能是纯计时/分类那一半——一个库,而非服务。
+**统一的超时*插件* / `ctx.timeout` 服务。** 基于微内核原则否决。一个能停止任何工具工作的服务必须理解每个能力的终止机制(原生 scope 或 Job 终止、fallback 进程组信号、socket 拆除、系统调用边界检查),这正是架构所禁止的「内核知道太多」。Codex 的 `ExecExpiration` 被限定于 exec 族,正是因为它驱动的 kill(`killpg`)是进程族特有的;MCP 和模型流各自保有自己的。不存在一个连贯的中间层能为所有东西拥有终止权,因此共享部分只能是纯计时/分类那一半——一个库,而非服务。
 
 **每个工具各自实现超时,不共享代码(先前的现状,也是 Claude Code 的选择)。** 否决,因为它已经在产生分化和重复的正确性负担:web_fetch 手写了与未来网络/进程类工具各自需要重新推导的完全相同的 controller/reason 逻辑,而融合 + `signal.reason` 恢复正是容易出错的部分。Claude Code 容忍完全重复;本仓库有一个统一的共享 abort 通道(每次 `execute` 上的 `exec.signal`),使得采用一个小型共享原语明显更简洁,因此成本/收益不同。
 
 **用 `withTimeout(promise, ms)` 包装器代替信号工厂。** 否决,因为让 promise 与定时器竞争只是在截止时间到达时 resolve *工具调用*的 promise,而不会停止底层工作——子进程或 fetch socket 会泄漏。分发信号并要求能力监听,才能强制一条真实的终止路径存在。这与「dispose 必须达到完全停稳,而非仅仅请求它」的防御性规则一致。
 
-**保留 bash 独立的超时和取消触发器。** 否决,因为一个 deadline 信号移除了定制定时器并标准化了分类。发生竞争时,报告先到达的那个 abort 作为原因,而既有的 SIGTERM→SIGKILL 终止路径保持不变
+**保留 bash 独立的超时和取消触发器。** 否决,因为一个 deadline 信号移除了定制定时器并标准化了分类。发生竞争时,报告先到达的那个 abort 作为原因,由提供方管理的终止路径不受哪个原因先胜出的影响

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md
-2026-07-10-single-file-executable-sdk-runtime-distribution.md: 756cf419df2dff70973eee9c1598950158dbfc1d
-2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: aa9c1c1b93b19a8310fff65bdfafa54f237c0e3c
+2026-07-10-single-file-executable-sdk-runtime-distribution.md: 5533c38d635d04c8799658fc1f6bcec8543d1dab
+2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 05800082034f0d1bd5034fc5bb17f09356d84dd9

Plik diff jest za duży
+ 0 - 0
.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md


Plik diff jest za duży
+ 0 - 0
.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md


+ 2 - 2
.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md
-2026-07-16-explicit-turn-cancellation.md: ee4253ba607d3468c05b61d6fb0fbc5efcf9f9d7
-2026-07-16-explicit-turn-cancellation.zh.md: d98d2c114056c44f131a2fe989f0b1f544127976
+2026-07-16-explicit-turn-cancellation.md: 09631349226837d7f93112fe5e9c517f2fee099c
+2026-07-16-explicit-turn-cancellation.zh.md: baf039d49578895d53ffae4c6b88d29b3d1563b6

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md

@@ -20,7 +20,7 @@ AgentLoop privately owns one `TurnCancellation` per prospective turn. It install
 
 The driver keeps only a cause-less pre-run marker for queued work cancelled before a turn is claimed. An effective `cancel()` emits the observe-only `agent/cancel-requested` notification with its resolved typed cause before clearing queued and steering work or aborting the holder; notification failures cannot veto the stop, and an idle call emits nothing. Work synchronously queued by a notification observer is included in that clear, while work queued by a later signal abort observer is latched and runs when the aborted activity converges to idle — a `disposed` cancel leaves it parked ([cancel-convergence wake latch](../bug-fix/2026-08-07-cancel-convergence-wake-latch.md)). If a `running` listener synchronously cancels old work and sends a replacement, the driver discards the aborted holder and creates a fresh one for the replacement. Repeated cancellation is first-wins for the active holder, while later calls may still clear newly queued pending work.
 
-The explicit event signatures pass a single payload object: agent-scoped events carry `agent` and `signal` in the payload with `next` last, and the remaining APIs keep `signal` immediately before a waterfall's final `next`. `PreStepContext` and `RequestFailureContext` are retired, with their fields folded into the `agent/pre-step` and `agent/request-error` payloads ([payload-object events](../../archived/architecture/2026-08-06-agent-event-payload-objects.md)). Pre-step entry, request configuration, request-error recovery, model generation, tool execution, approval, turn stopping, and subagent or workflow requests all receive the current signal. Hook bridges must also supply `RunHookOptions.signal`, so a turn cancellation reaches the bash executor's process-group kill and join boundary. `SystemPrompt.assemble()` carries `signal?: AbortSignal` in `AssembleContext` because that object is an explicit request value that can also represent signal-less assembly outside a turn. Listeners may cooperate with the signal but must not retain it to control another turn.
+The explicit event signatures pass a single payload object: agent-scoped events carry `agent` and `signal` in the payload with `next` last, and the remaining APIs keep `signal` immediately before a waterfall's final `next`. `PreStepContext` and `RequestFailureContext` are retired, with their fields folded into the `agent/pre-step` and `agent/request-error` payloads ([payload-object events](../../archived/architecture/2026-08-06-agent-event-payload-objects.md)). Pre-step entry, request configuration, request-error recovery, model generation, tool execution, approval, turn stopping, and subagent or workflow requests all receive the current signal. Hook bridges must also supply `RunHookOptions.signal`, so a turn cancellation reaches the bash executor's provider-managed termination and join boundary; the [native-containment decision](2026-08-28-subprocess-native-containment.md) owns the supported local scope, Job, and fallback mechanics. `SystemPrompt.assemble()` carries `signal?: AbortSignal` in `AssembleContext` because that object is an explicit request value that can also represent signal-less assembly outside a turn. Listeners may cooperate with the signal but must not retain it to control another turn.
 
 `ctx.agents` continues to carry only the initiating Agent. Ambient Agent presence does not imply liveness, a current turn, or cancellation authority. The cause reader is private to the loop and states the machine-private slot invariant (only `cancel()` aborts a turn controller, always with a canonical frozen cause) instead of re-validating the reason structurally; no public helper reads a cause off an arbitrary signal. Concurrent Agents isolate both their initiator identities and their turn signals; a child driver shadows the parent initiator while its parent request signal still travels through the subagent seam.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md

@@ -20,7 +20,7 @@ AgentLoop 为每个待启动轮次私有地持有一个 `TurnCancellation`。它
 
 对于轮次被认领前已取消的排队工作,驱动器只保留一个不携带取消原因的运行前标记。实际生效的 `cancel()` 会先发出仅供观察的 `agent/cancel-requested` 通知并携带最终确定的类型化取消原因,然后才清除排队工作和 steering(中途引导)工作或中止持有者;通知失败不能阻止此次停止,空闲状态下调用则不发出任何通知。通知观察者同步加入队列的工作也会被这次清除,而稍后由 signal 中止观察者加入队列的工作会被锁存,并在被中止的活动收敛到空闲时执行——`disposed` 取消则将其停放([取消收敛窗口唤醒锁存](../bug-fix/2026-08-07-cancel-convergence-wake-latch.zh.md))。若 `running` 监听器同步取消旧工作并发送替代提示词,驱动器会丢弃已中止的持有者,并为替代提示词创建全新的持有者。同一活跃持有者上的重复取消遵循首次请求优先,后续调用仍可清除新入队的待处理工作。
 
-显式事件签名传递单个 payload 对象:agent 作用域事件在 payload 中携带 `agent` 和 `signal`,`next` 位于最后;其余 API 保持 `signal` 紧邻 waterfall(瀑布式事件)的最终 `next` 之前。`PreStepContext` 与 `RequestFailureContext` 已退役,其字段并入 `agent/pre-step` 与 `agent/request-error` 的 payload([payload-object 事件](../../archived/architecture/2026-08-06-agent-event-payload-objects.md))。进入 pre-step 时、请求配置、请求错误恢复、模型生成、工具执行、审批、轮次停止以及 subagent 或工作流请求都会收到当前 signal。钩子桥接器也必须提供 `RunHookOptions.signal`,使轮次取消能够到达 Bash 执行器终止进程组并等待其退出的边界。`SystemPrompt.assemble()` 在 `AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值,也可表示轮次之外不携带 signal 的组装。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。
+显式事件签名传递单个 payload 对象:agent 作用域事件在 payload 中携带 `agent` 和 `signal`,`next` 位于最后;其余 API 保持 `signal` 紧邻 waterfall(瀑布式事件)的最终 `next` 之前。`PreStepContext` 与 `RequestFailureContext` 已退役,其字段并入 `agent/pre-step` 与 `agent/request-error` 的 payload([payload-object 事件](../../archived/architecture/2026-08-06-agent-event-payload-objects.md))。进入 pre-step 时、请求配置、请求错误恢复、模型生成、工具执行、审批、轮次停止以及 subagent 或工作流请求都会收到当前 signal。钩子桥接器也必须提供 `RunHookOptions.signal`,使轮次取消能够到达 Bash 执行器由提供方管理的终止与等待边界;[原生 containment 决策](2026-08-28-subprocess-native-containment.zh.md)负责受支持本地路径上的 scope、Job 与 fallback 机制。`SystemPrompt.assemble()` 在 `AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值,也可表示轮次之外不携带 signal 的组装。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。
 
 `ctx.agents` 仍只携带发起 Agent。环境中的 Agent 并不代表存活、当前轮次或取消权限。cause 读取器是 loop 私有的,它直接陈述机器私有的 slot 不变量(只有 `cancel()` 会中止轮次控制器,且总是携带规范的冻结 cause),而不是对 reason 做结构化再校验;不存在从任意 signal 读取 cause 的公开辅助函数。并发 Agent 会同时隔离各自的发起方身份和轮次 signal;子驱动会遮蔽父发起方,而父请求 signal 仍通过 subagent seam 传递。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md
-2026-07-28-portable-execution-world-consumers.md: 760005f1460890222c5af1ea0ec4eaf9cb29f360
-2026-07-28-portable-execution-world-consumers.zh.md: d78b04a5af785be0707d06e519819f95c524d92d
+2026-07-28-portable-execution-world-consumers.md: 787fe341e58cc212c99e0f35f07eea8e83daf000
+2026-07-28-portable-execution-world-consumers.zh.md: a558a5af64437b8743e741ace4ccf27079501721

+ 3 - 3
.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md

@@ -18,13 +18,13 @@ Ordinary pipes do not cover one requirement. A persistent terminal needs PTY all
 
 The filesystem interface owns the path facts that another capability needs without exposing its opaque target identity: a canonical process path, canonical `file:` URI, and containment. Existing whole and streaming text operations remain filesystem-owned; protocol consumers enforce their own retention limits while consuming the stream.
 
-The subprocess interface owns executable lookup and process primitives: ordinary raw or collected process spawning and `spawnTerminal()`. The terminal operation is one deep primitive whose handle owns text I/O, foreground groups, signalling, and one awaited TERM-to-KILL operation that settles in-flight handle calls and reaches quiescence for every session member the provider can still observe. Its signal cancels allocation only; the published handle owns its lifetime. Prompt detection, idle inference, scrollback, sandbox policy, and owner lifecycle remain in the PTY consumer.
+The subprocess interface owns executable lookup and process primitives: ordinary raw or collected process spawning and `spawnTerminal()`. An ordinary handle keeps target identity private: `.done` reports the direct target, while `terminate()` and `waitForExit()` control and observe the same provider-managed range. The [native-containment decision](2026-08-28-subprocess-native-containment.md) owns local Linux scopes, Windows Jobs, and their disclosed fallbacks. The terminal operation is one deep primitive whose handle owns text I/O, foreground groups, signalling, and one awaited TERM-to-KILL operation that settles in-flight handle calls and reaches quiescence for every member of its provider-owned range; an observational fallback limits that range to identities it can still observe. Its signal cancels allocation only; the published handle owns its lifetime. Prompt detection, idle inference, scrollback, sandbox policy, and owner lifecycle remain in the PTY consumer.
 
 Generic consumers use that execution world:
 
 - `dsh-bash-local` continues to map Bash semantics onto ordinary `ctx.subprocess.spawn()`.
 - `dsh-lsp-stdio` reads and contains source through `ctx.fs`, resolves and launches language servers through `ctx.subprocess`, and carries provider-owned file URIs through initialization and result rendering. One provider-lifetime signal aborts filesystem and protocol work during disposal, including workspace lookup before queue ownership; its JSON-RPC, pooling, synchronization, and normalization stay unchanged.
-- `dsh-terminal-bash` maps persistent-shell semantics onto `ctx.subprocess.spawnTerminal()`. The local `node-pty` and process-inspection implementation moves into `dsh-subprocess-local`; another subprocess provider supplies the same primitive. `danger-full-access` needs no `ctx.sandbox`; a confined mode requires a same-world sandbox provider and fails before spawn when none is mounted. Prompt and silence evidence collected during asynchronous pre-write inspection is discarded when the provider write begins. Cancellation retains the send reservation while an in-flight write settles and then signals the foreground group, so late bytes or the signal cannot target a successor; an in-flight readiness poll cannot release that reservation, and a rejected write sends no signal. The absolute deadline remains armed throughout cancellation. A signal failure becomes terminal transport failure. Completion of a stale inspection resumes polling for the current send. Startup cancellation begins terminal rollback without waiting for a stalled readiness or signalling call. Close rejects new public signals and delegates provider-observable session quiescence to the handle's awaited termination operation.
+- `dsh-terminal-bash` maps persistent-shell semantics onto `ctx.subprocess.spawnTerminal()`. The local `node-pty` and process-inspection implementation moves into `dsh-subprocess-local`; another subprocess provider supplies the same primitive. `danger-full-access` needs no `ctx.sandbox`; a confined mode requires a same-world sandbox provider and fails before spawn when none is mounted. Prompt and silence evidence collected during asynchronous pre-write inspection is discarded when the provider write begins. Cancellation retains the send reservation while an in-flight write settles and then signals the foreground group, so late bytes or the signal cannot target a successor; an in-flight readiness poll cannot release that reservation, and a rejected write sends no signal. The absolute deadline remains armed throughout cancellation. A signal failure becomes terminal transport failure. Completion of a stale inspection resumes polling for the current send. Startup cancellation begins terminal rollback without waiting for a stalled readiness or signalling call. Close rejects new public signals and delegates provider-managed session quiescence to the handle's awaited termination operation.
 
 ## E2B POC boundary
 
@@ -68,6 +68,6 @@ A remote execution provider implements only its shared sandbox owner plus filesy
 
 The fundamental interfaces are wider, and a filesystem/subprocess pair must agree on one execution world. The added operations are limited to facts and lifecycle mechanics that current generic consumers require; model schemas, protocol framing, readiness policy, and presentation do not leak into the providers.
 
-The local implementation absorbs `node-pty` and platform process inspection because it owns local terminal mechanics. This moves code without weakening terminal teardown: disposal sweeps descendants before and after terminating the top-level shell, waits for exact PID-identity-fenced descendants retained during foreground inspection, and retains Linux session members that survive top-level exit. macOS cannot enumerate a POSIX session after its leader exits, so a child that reparents between inspection snapshots remains an explicit local-provider limitation rather than a reason to move process mechanics back into the PTY consumer.
+The local implementation absorbs `node-pty` and platform process inspection because it owns local terminal mechanics. On supported Linux hosts, the user-systemd scope retains descendants that call `setsid` or reparent, while process inspection continues to own foreground attribution and synchronous fallback evidence. Other hosts use the observational teardown: disposal sweeps descendants before and after terminating the top-level shell, waits for exact PID-identity-fenced descendants retained during foreground inspection, and retains Linux session members that survive top-level exit. macOS cannot enumerate a POSIX session after its leader exits, so a child that reparents between inspection snapshots remains an explicit local-provider limitation rather than a reason to move process mechanics back into the PTY consumer.
 
 The E2B composition demonstrates that a shared sandbox owner plus filesystem and subprocess adapters are sufficient to move the mutable coding world off-host while leaving higher capabilities provider-neutral. Its POC limits remain explicit: the SDK retains complete command transport in host memory, remote startup cannot publish a PID synchronously, exact terminal stdin-wait and independent signal facts are unavailable, numeric PID/PGID operations are not identity-fenced, the initial environment probe cannot hide unknown sandbox-default secrets from already-running same-UID processes, and adapter artifacts remain until sandbox deletion. These are provider constraints, not justification for compatibility shims or more E2B packages.

+ 3 - 3
.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.zh.md

@@ -18,13 +18,13 @@ Status: implemented
 
 文件系统接口负责其他能力需要的路径事实,同时不公开其不透明目标身份:规范化进程路径、规范化 `file:` URI 和包含关系。现有完整文本与流式文本操作仍归文件系统负责;协议消费方在消费流时执行各自的保留上限。
 
-进程管理接口负责可执行文件查找与进程原语:以原始或收集模式 spawn 普通进程,以及 `spawnTerminal()`。终端操作是一项深层原语,其句柄负责文本 I/O、前台进程组、信号发送,以及一项须等待的 TERM→KILL 操作;该操作会结算所有在途句柄调用,并使提供方仍可观察到的每个会话成员完全停稳。其信号只取消分配;句柄一经发布,便负责自身生命周期。提示符检测、空闲推断、scrollback、沙箱策略和所有者生命周期仍由 PTY 消费方负责。
+进程管理接口负责可执行文件查找与进程原语:以原始或收集模式 spawn 普通进程,以及 `spawnTerminal()`。普通句柄把 target identity 保持为私有事实:`.done` 报告 direct target,`terminate()` 与 `waitForExit()` 则控制并观察同一个由提供方管理的范围。[原生 containment 决策](2026-08-28-subprocess-native-containment.zh.md)负责本地 Linux scope、Windows Job 及其已声明的 fallback。终端操作是一项深层原语,其句柄负责文本 I/O、前台进程组、信号发送,以及一项须等待的 TERM→KILL 操作;该操作会结算所有在途句柄调用,并使提供方拥有的范围中每个成员完全停稳;观察型 fallback 只能把该范围限制为它仍可观察到的 identity。其信号只取消分配;句柄一经发布,便负责自身生命周期。提示符检测、空闲推断、scrollback、沙箱策略和所有者生命周期仍由 PTY 消费方负责。
 
 通用消费方使用该执行世界:
 
 - `dsh-bash-local` 继续把 Bash 语义映射到普通的 `ctx.subprocess.spawn()`。
 - `dsh-lsp-stdio` 通过 `ctx.fs` 读取源文件并验证包含关系,通过 `ctx.subprocess` 解析和启动语言服务器,并让由提供方负责的文件 URI 贯穿初始化与结果渲染。一个提供方生命周期信号会在资源释放期间中止文件系统与协议操作,包括取得队列所有权之前的工作区查找;其 JSON-RPC、池化、同步和规范化保持不变。
-- `dsh-terminal-bash` 把持久 shell 语义映射到 `ctx.subprocess.spawnTerminal()`。本地 `node-pty` 与进程检查实现移入 `dsh-subprocess-local`;其他进程管理提供方则提供相同原语。`danger-full-access` 不需要 `ctx.sandbox`;受限模式要求同一执行世界中存在沙箱提供方,未挂载时会在 spawn 前失败。提供方开始写入时,系统会丢弃异步写入前检查期间收集的提示符与静默证据。取消会在在途写入结算期间保留发送预留,随后向前台进程组发送信号,因此延迟字节和该信号都无法落到后续发送;在途就绪检查无法释放该预留,写入被拒绝时也不会发送信号。绝对截止时间会在整个取消期间保持启用。信号发送失败会成为终结性传输失败。陈旧检查完成后,会针对当前发送恢复轮询。启动取消会立即开始终端回滚,而不等待停滞的就绪检查或信号发送调用。关闭操作会拒绝新的公开信号,并把提供方可观察会话成员的完全停稳委托给句柄上须等待的终止操作。
+- `dsh-terminal-bash` 把持久 shell 语义映射到 `ctx.subprocess.spawnTerminal()`。本地 `node-pty` 与进程检查实现移入 `dsh-subprocess-local`;其他进程管理提供方则提供相同原语。`danger-full-access` 不需要 `ctx.sandbox`;受限模式要求同一执行世界中存在沙箱提供方,未挂载时会在 spawn 前失败。提供方开始写入时,系统会丢弃异步写入前检查期间收集的提示符与静默证据。取消会在在途写入结算期间保留发送预留,随后向前台进程组发送信号,因此延迟字节和该信号都无法落到后续发送;在途就绪检查无法释放该预留,写入被拒绝时也不会发送信号。绝对截止时间会在整个取消期间保持启用。信号发送失败会成为终结性传输失败。陈旧检查完成后,会针对当前发送恢复轮询。启动取消会立即开始终端回滚,而不等待停滞的就绪检查或信号发送调用。关闭操作会拒绝新的公开信号,并把由提供方管理的会话完全停稳委托给句柄上须等待的终止操作。
 
 ## E2B POC 边界
 
@@ -68,6 +68,6 @@ E2B 负责可变文件系统、受管命令与 Bash 进程、终端分配与终
 
 基础接口更宽,一对文件系统/进程管理提供方必须在同一个执行世界上保持一致。新增操作仅限当前通用消费方所需的事实与生命周期机制;模型 schema、协议分帧、就绪策略和呈现不会渗入提供方。
 
-本地实现承接 `node-pty` 和平台进程检查,因为它负责本地终端机制。这种代码迁移不会削弱终端拆卸:dispose(资源释放)会在终止顶层 shell 前后清理后代进程,等待前台检查期间保留下来且受精确 PID 身份围栏保护的后代进程,并继续追踪在顶层进程退出后仍存活的 Linux 会话成员。macOS 无法在 POSIX 会话 leader 退出后枚举该会话,因此在两次检查快照之间重新设定父进程的子进程仍是明确的本地提供方限制,而不是把进程机制移回 PTY 消费方的理由。
+本地实现承接 `node-pty` 和平台进程检查,因为它负责本地终端机制。在受支持的 Linux 宿主上,user-systemd scope 会保留调用 `setsid` 或发生 reparent 的后代,进程检查则继续负责前台归属与同步 fallback 证据。其他宿主使用观察型拆卸:dispose(资源释放)会在终止顶层 shell 前后清理后代进程,等待前台检查期间保留下来且受精确 PID 身份围栏保护的后代进程,并继续追踪在顶层进程退出后仍存活的 Linux 会话成员。macOS 无法在 POSIX 会话 leader 退出后枚举该会话,因此在两次检查快照之间重新设定父进程的子进程仍是明确的本地提供方限制,而不是把进程机制移回 PTY 消费方的理由。
 
 E2B 组合证明,共享沙箱所有者加上文件系统与进程管理适配器,就足以在保持上层能力与提供方无关的同时,把可变编码世界移出宿主。其 POC 限制仍明确在案:SDK 会把完整命令传输内容保留在宿主内存中;远程启动无法同步发布 PID;无法获得精确的终端 stdin 等待状态与独立信号事实;基于数值 PID/PGID 的操作没有身份围栏;初始环境探测无法向已在运行的同 UID 进程隐藏未知的沙箱默认 secret;适配器产物会一直保留到沙箱删除。这些是提供方限制,不是引入兼容性 shim 或更多 E2B 包的理由。

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.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-22-single-dsh-application-launcher.md
-2026-08-22-single-dsh-application-launcher.md: 21fc99790c506f4e3f6e8b3ab572121e1cf1a882
-2026-08-22-single-dsh-application-launcher.zh.md: 4175bd675889ec01db46ebca157aff35c9ef9cab
+2026-08-22-single-dsh-application-launcher.md: 630040c75c4c20c57b5e26288663265174331ca5
+2026-08-22-single-dsh-application-launcher.zh.md: 9bd51e6c4e8b7ab60cbabc384431bc4e22a6b522

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md

@@ -46,7 +46,7 @@ Direct SDK use follows normal Harness-home resolution: explicit `dshHome`, inher
 
 ### Python runtime
 
-The Python runtime wheel packages the ordinary `@deepseek-ai/dsh` CLI from `node_modules/@deepseek-ai/dsh/lib/bin.js` through the private `dsh-python-runtime-closure` deploy manifest. The Python client selects `dsh --profile sdk` by default, ordered patch files, and an explicit Harness home; the runnable example under `python/sdk/examples` selects `sdk-minimal`. The installed `dsh` console command exposes the same profile grammar and the separately packaged `web` application.
+The Python runtime wheel stages [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) as the `dsh-python-runtime-closure` entry. Its ordinary branch calls the public CLI export; a provider-private selector dispatches to the internal subprocess runner before CLI parsing and is not an application entry point. The [native-containment decision](2026-08-28-subprocess-native-containment.md) owns that private dispatch. The Python client selects `dsh --profile sdk` by default, ordered patch files, and an explicit Harness home; the runnable example under `python/sdk/examples` selects `sdk-minimal`. The installed `dsh` console command exposes the same profile grammar and the separately packaged `web` application.
 
 The executable family is `deepseek-harness-sdk-runtime-<platform>-<arch>`. The SDK wire, wheel and import distribution names, sidecar names, and wire identity `deepseek-harness-sdk-runtime` remain stable. The SDK package family is `@deepseek-ai/dsh-sdk-client`, `@deepseek-ai/dsh-sdk-protocol`, and `@deepseek-ai/dsh-sdk-jsonrpc-server`; `@deepseek-ai/dsh-acp` remains the ACP protocol plugin. There is no Python-specific Node application, checked-in complete config, compatibility package, forwarding executable, fallback parser, or SDK/ACP launcher alias. [docs/architecture.md](../../../../docs/architecture.md) owns this launch, and the [`python/sdk-runtime` README](../../../../python/sdk-runtime/README.md) owns the Windows carrier.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md

@@ -46,7 +46,7 @@ SDK 用户通过 profile 自定义插件。`dsh plugin --profile <name> ...` 管
 
 ### Python 运行时
 
-Python 运行时 wheel 通过私有 `dsh-python-runtime-closure` 部署 manifest,打包来自 `node_modules/@deepseek-ai/dsh/lib/bin.js` 的普通 `@deepseek-ai/dsh` CLI。Python 客户端默认选择 `dsh --profile sdk`、有序 patch 文件与显式 Harness home;`python/sdk/examples` 下的可运行示例选择 `sdk-minimal`。安装的 `dsh` 控制台命令暴露相同 profile 语法与单独打包的 `web` 应用。
+Python 运行时 wheel 将 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) 暂存为 `dsh-python-runtime-closure` 入口。其普通分支调用公开 CLI export;提供方私有选择会在 CLI 解析前分派到内部子进程 runner,而不是应用入口。[原生 containment 决策](2026-08-28-subprocess-native-containment.zh.md)负责该私有分派。Python 客户端默认选择 `dsh --profile sdk`、有序 patch 文件与显式 Harness home;`python/sdk/examples` 下的可运行示例选择 `sdk-minimal`。安装的 `dsh` 控制台命令暴露相同 profile 语法与单独打包的 `web` 应用。
 
 可执行文件族是 `deepseek-harness-sdk-runtime-<platform>-<arch>`。SDK 协议格式、wheel 与 import 分发名称、伴随文件名称,以及协议 identity `deepseek-harness-sdk-runtime` 保持稳定。SDK 包族是 `@deepseek-ai/dsh-sdk-client`、`@deepseek-ai/dsh-sdk-protocol` 与 `@deepseek-ai/dsh-sdk-jsonrpc-server`;`@deepseek-ai/dsh-acp` 继续作为 ACP 协议插件。仓库不保留 Python 专用 Node 应用、检入的完整配置、兼容包、转发可执行文件、后备解析器或 SDK/ACP 启动别名。[docs/architecture.md](../../../../docs/architecture.zh.md)负责该启动方式,[`python/sdk-runtime` README](../../../../python/sdk-runtime/README.zh.md)负责 Windows 载体。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.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-25-session-observations-and-projection-owned-client-state.md
-2026-08-25-session-observations-and-projection-owned-client-state.md: 71c0aa92ddb2ad5bacb238dcc7f6e44e33f84305
-2026-08-25-session-observations-and-projection-owned-client-state.zh.md: f8d4a629501dd354e1210bccab1a3a3862b8c6c3
+2026-08-25-session-observations-and-projection-owned-client-state.md: ff5628c3051086159d532b71ca3e98c1b41a51f8
+2026-08-25-session-observations-and-projection-owned-client-state.zh.md: e433df28fec86564fe1c1e9e88686a5abe6b9549

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md

@@ -51,7 +51,7 @@ Every owner disposes its observation. `retain()` creates another lease over the
 
 ### Source resolution and lifetime
 
-An observation binds all returned fields to one lifecycle witness. Callers do not combine a header from corpus listing, events from persistence, and projections from a later live Session. The selected header and event prefix produce the cursor and projection snapshot together.
+An observation binds all returned fields to one lifecycle witness. Callers do not combine a header from corpus listing, events from persistence, and projections from a later live Session. The selected header and event prefix produce the cursor and projection snapshot together. A live observation fixes its cut as the log length at read time and materializes `events` on the first access; the log only appends, so that prefix is identical however late a consumer reads it, and a consumer that needs only the header, cursor, or projections never copies the log.
 
 Live preference is checked both before and after a cold borrow. The second check closes the race in which an Agent attaches while persistence is loading. If persistence itself reports that a live source won but that source has already detached by the time SessionQuery examines it, resolution restarts instead of publishing an unowned reference.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md

@@ -51,7 +51,7 @@ flowchart LR
 
 ### 数据源解析与生命周期
 
-一份 observation 把所有返回字段绑定到同一 lifecycle witness。调用方不会把 corpus list 的 header、persistence 的 events 和稍后 live Session 的 projections 拼在一起。选中的 header 与事件前缀共同产生 cursor 和 projection snapshot。
+一份 observation 把所有返回字段绑定到同一 lifecycle witness。调用方不会把 corpus list 的 header、persistence 的 events 和稍后 live Session 的 projections 拼在一起。选中的 header 与事件前缀共同产生 cursor 和 projection snapshot。live observation 在读取时以日志长度固定 cut,并在首次访问时才物化 `events`;日志只会追加,所以无论消费者多晚读取,该前缀都完全相同,而只需要 header、cursor 或 projections 的消费者永远不会复制日志。
 
 系统在 cold borrow 前后都检查 live 优先级。第二次检查封住 persistence 加载期间 Agent 完成 attach 的竞态。如果 persistence 报告由 live source 胜出,但 SessionQuery 检查时该 source 已经 detach,解析会重新开始,而不是发布一份无人持有的引用。
 

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

+ 83 - 0
.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md

@@ -0,0 +1,83 @@
+# Agent Note: Native owners contain escaped subprocess descendants
+
+Status: implemented
+
+English | [中文](2026-08-28-subprocess-native-containment.zh.md)
+
+## Problem
+
+Detached POSIX process groups, Windows direct-parent traversal, and PTY descendant scans describe only members that remain observable through one process relationship. A child can call `setsid`, reparent, or outlive its direct parent and leave those ranges, so terminating the apparent tree can return while work, ports, or files remain active. A direct target result also does not prove that every descendant has stopped.
+
+The ordinary subprocess handle cannot solve this gap by publishing a PID. Linux scope setup and the Windows Job runner establish target identity asynchronously, a PID does not name the complete managed range, and consumers would be forced to infer whether startup committed. The public result, range ownership, private startup protocol, and packaged entry therefore need separate owners.
+
+## Decision
+
+`LocalSubprocessRuntime` selects one provider-private managed-range owner before a target can execute. Eligible Linux ordinary and PTY launches enter a transient user-systemd scope; eligible Windows ordinary launches enter an unnamed kill-on-close Job owned by a private runner. Unsupported hosts use the existing weaker fallback with one provider-lifetime warning. The provider never replays a target after a selected native path may have executed it.
+
+An ordinary `SubprocessHandle` has no PID or public startup state. `.done` reports the direct target result or startup/provider failure, `terminate()` signals the selected range, and `waitForExit()` succeeds only after that same range is proven empty. `SubprocessTerminalHandle.pid` remains part of the terminal contract because PTY identity and foreground inspection require it.
+
+### Linux scope and one-shot bootstrap
+
+The first eligible Linux ordinary or PTY call in one runtime deeply checks the exact runner entry, the libc `execve` and `fcntl` bindings, the readable user manager, and literal-argv transient-scope support. Failed deep probes are retried, while the first success is cached. Each later eligible call still performs a lightweight user-manager reachability probe before target execution. Once selected, a scope, protocol, state-query, or pre-exec failure is reported through that launch and never switches to fallback.
+
+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.
+
+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.
+
+### Windows runner and Job
+
+The Windows parent starts the provider runner from a bootstrap cwd and environment, gives it the original target argv after a private `--` delimiter, and waits for Node's runner `spawn` event before sending exactly one start request. A pre-spawn runner error preserves that direct launch failure while proving that no Job range existed, so the empty-range wait succeeds; post-spawn infrastructure failure remains uncertain and rejects range settlement. Node IPC otherwise carries idempotent terminate control and exactly one result. Runner fd 0 through fd 2 are isolated, fd 3 carries IPC, and fd 4 through fd 6 carry target stdin, stdout, and stderr. Fd 4 inherits the platform null-device descriptor when stdin is ignored and uses a pipe otherwise. The shared Win32 layer maps fds 4 through 6 to OS handles through Node's exported `uv_get_osfhandle()`, rejects null plus the unsigned `UV_INVALID_OS_FILE_HANDLE` and `UV_INVALID_FILE_DESCRIPTOR` sentinels exposed by Koffi, temporarily enables inheritance on valid handles, and passes them through `STARTF_USESTDHANDLES`. `spawnCurrentTokenJobProcess` requires a separately resolved `applicationName` and a complete target environment, which it sends as a sorted, double-NUL-terminated UTF-16LE block with `CREATE_UNICODE_ENVIRONMENT`, including `=X:` drive entries, without mutating the runner environment. After the suspended target enters the Job and resumes, the runner closes only fd 4 through fd 6; it never mutates or destroys Node's standard streams. The parent returns the pipe carriers as the ordinary handle's stdio, and user bytes never pass through IPC.
+
+The runner is the sole owner of the target process handle and unnamed Job handle. `spawnCurrentTokenJobProcess` creates the target suspended, assigns it to a kill-on-close Job that disallows active breakaway, and resumes it only after assignment. The runner polls the direct process for the target exit code and the Job for active-process count. It exits successfully only after the direct result has been delivered through the IPC send callback and the Job has reported zero active processes; the parent maps only that clean exit to successful `waitForExit()`.
+
+The parent permanently latches a validated numeric `target-exit` as soon as it arrives, before the existing stdout/stderr close or bounded-drain barrier settles. `.done` waits only for that stdio barrier and then returns the latched result. A later Job query, range-settlement failure, IPC loss, or abnormal runner exit rejects only `waitForExit()` and cannot replace the direct result. Infrastructure failure before a valid target result rejects `.done`, but that rejection does not reveal whether target execution began. On disconnect or result-send failure, the runner stops protocol work, terminates and closes its only Job handle, and exits nonzero. Closing the last Job handle kills remaining members but does not convert the disconnected path into a successful quiescence proof.
+
+### Private dispatch and protocol
+
+Source launches execute the package runner entry through the TypeScript source launcher, built launches resolve the `@deepseek-ai/dsh-subprocess-local/runner` export, and the Python SDK single-file executable enters through [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs). The Python runtime owns that bootstrap. It calls the public CLI export when the private selector is absent; otherwise it removes the selector and dispatches to the same subprocess runner core. The public `dsh` argument parser has no hidden runner mode, `apps/cli` builds only its public entry, and packaging ships no second Node executable.
+
+The selector is a per-spawn locator or sentinel, not a credential or persistent format. Linux uses one strict request plus one optional strict startup-error file. Windows uses one IPC channel with closed `start` and `terminate` requests and exactly two result branches: `target-exit` with a numeric `exitCode`, and `error` with required `name` and `message` plus only optional `code`, `syscall`, and `path`; the parent derives `signal: null`. Pre-commit cancellation uses the same ordinary `error` record. The cancellation reason never crosses the wire, so a parent cancellation latch restores its first local reason exactly, including `null` or `undefined`. Missing, extra, mistyped, or unknown fields fail closed. Target environments may contain the selector name, including Windows case variants, because the provider transmits target state separately and restores it only after private selection is consumed.
+
+### Fallback and cleanup
+
+Linux falls back before target execution when the exact bootstrap, modern readable user-systemd manager, or literal-argv scope is unavailable. Windows ordinary launch falls back when the runner entry, Win32 bindings, or current-token Job probe is unavailable. macOS ordinary launch, Windows ConPTY, and other unsupported hosts retain their existing PGID, `taskkill /T`, or identity-fenced PTY observation. The warning states that descendants escaping those observable relationships are not guaranteed to terminate or delay `waitForExit()`.
+
+Normal Cordis disposal starts direct-result and range observation independently, requests termination, and waits for every owned range. Consumer teardown does not inspect an ordinary PID; it retains the original operation or startup error while attempting terminate and final wait, and preserves cleanup failures in the consumer's existing error order. A confirmed empty range permanently disables later signalling against stale identities.
+
+During a JavaScript-observable host exit, `LocalSubprocessRuntime` synchronously force-terminates every still-live handle without promises or timers. Linux sends the existing direct fallback kill and the exact scope kill; Windows kills the runner so its only Job handle closes; PTY fallback scans remain best effort. Per-handle failures are contained and do not change the host's exit result. Termination modes in which JavaScript cannot run remain outside this listener's guarantee.
+
+## Existing decisions and supersession
+
+This note owns the current native-containment mechanism. It partially updates the provider and no-PID facts in the [subprocess seam](../../archived/architecture/2026-07-26-subprocess-seam.md), the Linux teardown facts in [persistent PTY sessions](../feature/2026-07-16-persistent-pty-sessions.md), the native targets used by [synchronous host-exit cleanup](../../archived/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md), the ordinary consumer of [shared Win32 process primitives](../../archived/architecture/2026-08-19-shared-win32-process-primitives.md), and the private entry selected by the [Python SDK profile runtime](../../archived/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md). Each note retains its other decision and remains active.
+
+## 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.
+- 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.
+- 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.
+
+## Alternatives considered
+
+**Keep PID, make it optional, or add a public `started` promise.** Rejected because each representation exposes an asynchronous provider identity that does not name the managed range and invites consumers to infer startup or quiescence from the wrong fact.
+
+**Extend process-group, session, parent-tree, or PID scanning.** Rejected because a process can leave each observed relationship; broader SID signalling can also reach unrelated processes when a PTY helper shares a launcher session. Native OS membership is persistent and independently queryable.
+
+**Let the parent own or reopen the Windows Job.** Rejected because copied handles, named Jobs, `OpenJobObject`, process-handle handoff, and completion ports create multiple lifecycle owners without improving the direct-result contract. One runner can own target creation, Job membership, result production, and final handle closure.
+
+**Carry control or results through target stdio or files on Windows.** Rejected because user bytes and EOF must remain authoritative to existing Node streams, while result files or polling introduce a second result owner. One IPC channel separates control from target stdio.
+
+**Parse a hidden runner argument in the public CLI or ship another Node executable.** Rejected because either choice expands the public application grammar or distribution surface. A packaging-only bootstrap keeps one physical executable and two private logical entries.
+
+**Cache the complete Linux native decision without another reachability check.** Rejected because user-manager availability can change between spawns. The runtime caches only the first successful deep bootstrap/scope probe and lightly rechecks manager reachability for every later eligible call.
+
+**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.
+
+## 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.
+
+Fallback hosts continue to run commands but carry a visible weaker guarantee. Windows ConPTY, macOS native containment, active breakaway descendants, old or absent user-systemd environments, target replay, persistent runner recovery, and termination paths where JavaScript cannot execute remain outside this decision.

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

@@ -0,0 +1,83 @@
+# Agent Note: 原生 owner 收容逃逸的 subprocess 后代
+
+Status: implemented
+
+[English](2026-08-28-subprocess-native-containment.md) | 中文
+
+## Problem
+
+detached POSIX 进程组、Windows direct-parent 遍历与 PTY 后代扫描只能描述仍可通过某种进程关系观察到的成员。子进程可以调用 `setsid`、发生 reparent,或比 direct parent 存活更久并离开这些 range,因此终止表面进程树后,工作、端口或文件仍可能保持活跃。direct target result 也不能证明全部后代已经停止。
+
+普通 subprocess 句柄无法通过发布 PID 解决这个缺口。Linux scope setup 与 Windows Job runner 会异步建立 target identity,PID 不表示完整 managed range,消费方也会被迫推断 startup 是否已经提交。因此,公共结果、range ownership、私有启动协议与打包入口需要各自明确的 owner。
+
+## Decision
+
+`LocalSubprocessRuntime` 会在 target 执行前选择一个 provider 私有的 managed-range owner。符合条件的 Linux 普通命令与 PTY 进入临时 user-systemd scope;符合条件的 Windows 普通命令进入由私有 runner 拥有的 unnamed kill-on-close Job。不支持的宿主使用既有较弱 fallback,并在 provider 生命周期内只警告一次。选定的 native 路径一旦可能已经执行 target,provider 绝不重放 target。
+
+普通 `SubprocessHandle` 没有 PID 或公共 startup 状态。`.done` 报告 direct target result 或 startup/provider failure,`terminate()` 向所选 range 发送信号,`waitForExit()` 只有在同一 range 被证明为空后才成功。`SubprocessTerminalHandle.pid` 继续属于终端约定,因为 PTY identity 与前台检查需要它。
+
+### Linux scope 与 one-shot bootstrap
+
+同一 runtime 第一次符合条件的 Linux 普通或 PTY 调用会深入检查准确 runner 入口、libc `execve` 与 `fcntl` bindings、可读的 user manager 与保留 literal argv 的 transient-scope 支持。失败的深度 probe 会重试,第一次成功则会缓存。之后每次符合条件的调用仍会在 target 执行前轻量检查 user manager 是否可达。native 路径一旦选定,scope、协议、状态查询或 pre-exec failure 都由本次启动报告,绝不切换到 fallback。
+
+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 的私有路径。
+
+普通 target result 仍来自同一个 child process。PTY 路径复用同一 request 与 bootstrap,但不增加常驻 runner,因此 `node-pty` PID、进程组、session leader、控制终端、前台 `inputWaiting`、`/dev/tty`、readiness 与 direct terminal outcome 保留既有含义,同时 scope membership 覆盖 `setsid` 与 reparent 后代。
+
+### Windows runner 与 Job
+
+Windows parent 从 bootstrap cwd 与环境启动 provider runner,把原始 target argv 放在私有 `--` 分隔符之后,并等待 Node 的 runner `spawn` 事件后才发送恰好一条 start request。runner 在 spawn 前报错时,direct launch failure 会原样保留,同时证明 Job range 从未存在,因此 empty-range wait 成功;spawn 后的 infrastructure failure 仍是不确定状态,会使 range settlement reject。除此之外,Node IPC 还承载幂等 terminate control 与恰好一个 result。runner 的 fd 0 至 fd 2 相互隔离,fd 3 承载 IPC,fd 4 至 fd 6 承载 target stdin、stdout 与 stderr。忽略 stdin 时,fd 4 继承平台 null-device descriptor;其他模式使用 pipe。共享 Win32 层通过 Node 导出的 `uv_get_osfhandle()` 把 fd 4 至 fd 6 映射为 OS handle,拒绝 null 以及 Koffi 暴露的 unsigned `UV_INVALID_OS_FILE_HANDLE` 与 `UV_INVALID_FILE_DESCRIPTOR` sentinel,临时启用有效 handle 的继承,并通过 `STARTF_USESTDHANDLES` 传入。`spawnCurrentTokenJobProcess` 要求单独解析的 `applicationName` 与完整 target 环境,并使用 `CREATE_UNICODE_ENVIRONMENT` 传入排序、双 NUL 结尾的 UTF-16LE 块,其中包括 `=X:` 驱动器条目,而不修改 runner 环境。suspended target 进入 Job 并恢复后,runner 只关闭 fd 4 至 fd 6;它绝不改写或销毁 Node 标准流。parent 把 pipe carrier stream 作为普通句柄的 stdio 返回,用户字节绝不经过 IPC。
+
+runner 是 target process handle 与 unnamed Job handle 的唯一 owner。`spawnCurrentTokenJobProcess` 以 suspended 状态创建 target,把它分配给不允许 active breakaway 的 kill-on-close Job,并只在分配后恢复。runner 轮询 direct process 获取 target exit code,并轮询 Job 获取 active-process count。只有 direct result 已通过 IPC send callback 交付且 Job 已报告零 active process 后,runner 才成功退出;parent 只把这次 clean exit 映射成成功的 `waitForExit()`。
+
+parent 会在收到经过校验、只含数字的 `target-exit` 时立即永久锁存它,此时既有 stdout/stderr close 或有界 drain barrier 可能尚未完成。`.done` 只继续等待该 stdio barrier,随后返回已锁存的结果。后续 Job query、range settlement failure、IPC loss 或 runner 异常退出只会使 `waitForExit()` reject,不能替换 direct result。在有效 target result 到达前发生 infrastructure failure 才会使 `.done` reject,但该 rejection 不会说明 target 是否已经开始执行。disconnect 或 result-send failure 会让 runner 停止协议工作、终止并关闭自己唯一的 Job handle,然后以非零状态退出。最后一个 Job handle 关闭会终止剩余成员,但不会把 disconnected 路径改写成成功的完全停稳证明。
+
+### 私有分派与协议
+
+source 启动通过 TypeScript source launcher 执行包内 runner 入口,built 启动解析 `@deepseek-ai/dsh-subprocess-local/runner` export,Python SDK 单文件可执行程序则从 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) 进入。该 bootstrap 由 Python runtime 拥有;私有 selector 不存在时,它调用公共 CLI export;否则会删除 selector,并分派到同一 subprocess runner core。公共 `dsh` 参数解析器没有隐藏 runner mode,`apps/cli` 只构建公共入口,打包也不提供第二个 Node 可执行程序。
+
+selector 是 per-spawn locator 或 sentinel,不是凭据或持久格式。Linux 使用一个严格 request 与一个可选严格 startup-error 文件。Windows 使用一条 IPC channel,承载闭集的 `start` 与 `terminate` request,以及恰好两个 result 分支:只含数字 `exitCode` 的 `target-exit`,以及必含 `name`、`message` 且只允许可选 `code`、`syscall`、`path` 的 `error`;parent 会派生 `signal: null`。提交前取消使用同一种普通 `error` record。取消 reason 不跨 wire 传递,因此 parent cancellation latch 会原样恢复第一个本地 reason,包括 `null` 或 `undefined`。缺失、额外、类型错误或未知字段都会 fail closed。target 环境可以包含 selector 名称及其 Windows 大小写变体,因为 provider 会单独传递 target 状态,并且只在私有选择值消费后才恢复该状态。
+
+### Fallback 与 cleanup
+
+准确 bootstrap、现代且可读的 user-systemd manager,或保留 literal argv 的 scope 不可用时,Linux 会在 target 执行前进入 fallback。runner 入口、Win32 bindings 或 current-token Job probe 不可用时,Windows 普通启动会进入 fallback。macOS 普通启动、Windows ConPTY 与其他不受支持的宿主保留既有 PGID、`taskkill /T` 或带身份围栏的 PTY 观察机制。warning 会明确说明:逃离这些可观察关系的后代不保证被终止,也不保证延迟 `waitForExit()`。
+
+正常 Cordis dispose 会独立启动 direct-result 与 range observation、请求终止,并等待每个自有 range。消费方 teardown 不检查普通 PID;它会保留原始 operation 或 startup error,同时尝试 terminate 与 final wait,并按消费方既有错误顺序保留 cleanup failure。range 一旦被确认为空,就会永久禁止后续向陈旧 identity 发送信号。
+
+在 JavaScript 可观察的 host exit 期间,`LocalSubprocessRuntime` 会同步强制终止每个仍存活的句柄,不使用 Promise 或 timer。Linux 会发送既有 direct fallback kill 与准确 scope kill;Windows 会终止 runner,使其唯一 Job handle 关闭;PTY fallback 扫描仍是 best effort。每个句柄的失败相互隔离,也不改变宿主退出结果。JavaScript 无法运行的终止形态不属于该 listener 的保证。
+
+## Existing decisions and supersession
+
+本 Note 拥有当前 native containment 机制。它局部更新了[subprocess seam](../../archived/architecture/2026-07-26-subprocess-seam.md)中的 provider 与 no-PID 事实、[持久化 PTY 会话](../feature/2026-07-16-persistent-pty-sessions.zh.md)中的 Linux teardown 事实、[宿主退出同步清理](../../archived/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md)使用的 native target、[共享 Win32 process primitives](../../archived/architecture/2026-08-19-shared-win32-process-primitives.md)的 ordinary 消费方,以及[Python SDK profile 运行时](../../archived/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md)选择的私有入口。每份 Note 都保留其余决策并继续处于 active 状态。
+
+## 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。
+- 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。
+- 真实 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 保留。
+
+## Alternatives considered
+
+**保留 PID、把它改成可选值,或增加公共 `started` Promise。**不予采用,因为这些表示都会暴露不表示 managed range 的异步 provider identity,并诱导消费方从错误事实推断 startup 或完全停稳。
+
+**扩展进程组、session、parent tree 或 PID 扫描。**不予采用,因为进程可以离开每一种观察关系;当 PTY helper 与 launcher 共用 session 时,更宽的 SID signalling 还可能命中无关进程。native OS membership 会持续存在,并且可以独立查询。
+
+**让 parent 拥有或重新打开 Windows Job。**不予采用,因为复制 handle、named Job、`OpenJobObject`、process-handle handoff 与 completion port 会制造多个 lifecycle owner,却不能改善 direct-result 约定。一个 runner 可以统一拥有 target creation、Job membership、result production 与最终 handle closure。
+
+**在 Windows 上通过 target stdio 或文件传递 control/result。**不予采用,因为用户字节与 EOF 必须继续以既有 Node stream 为权威,而 result file 或 polling 会制造第二个 result owner。一条 IPC channel 可以把 control 与 target stdio 分开。
+
+**在公共 CLI 中解析隐藏 runner 参数,或发布另一个 Node 可执行程序。**不予采用,因为前者扩张公共应用语法,后者扩张分发面。packaging-only bootstrap 保留一个物理可执行程序与两个私有逻辑入口。
+
+**缓存完整的 Linux native 选择结果且不再检查可达性。**不予采用,因为 user-manager availability 可以在两次 spawn 之间变化。runtime 只缓存第一次成功的 bootstrap/scope 深度 probe,并在之后每次符合条件的调用中轻量复查 manager reachability。
+
+**在 native launch 失败后重放命令。**不予采用,因为含糊 failure 可能发生在 target 已经执行之后,重放因此可能把命令执行两次。
+
+## Consequences
+
+受支持的 Linux 普通与 PTY 启动、Windows 普通启动会在后代逃离进程组或 direct parent 退出后继续拥有它们,同时 direct target result 与 range 完全停稳保持独立。代价是每次 spawn 都需要一次 Linux manager 检查与 scope/request,或一套 Windows runner/IPC/Job 生命周期,而且所选 owner 无法证明 settlement 时会显式失败。
+
+fallback 宿主继续运行命令,但携带可见的较弱保证。Windows ConPTY、macOS native containment、active breakaway 后代、旧版或缺失的 user-systemd 环境、target replay、持久 runner recovery,以及 JavaScript 无法执行的终止路径均不属于本决策。

+ 2 - 2
.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-06-sandbox.md
-2026-07-06-sandbox.md: ceffb260163bc21e2ed13275dde38ea0843aac7d
-2026-07-06-sandbox.zh.md: 2b6753585b2778bc0dc898661d9f21779dc37f18
+2026-07-06-sandbox.md: a6e5639e21ca7140cb0314c9918319e99b1495f6
+2026-07-06-sandbox.zh.md: 5144b3fa719465707d7fb2870d45094b8c07661a

+ 3 - 3
.agents/notes/implemented/feature/2026-07-06-sandbox.md

@@ -38,7 +38,7 @@ Four `cordis.yml` entries turn an unconfined coding agent into the sandboxed pro
 
 The swap is invisible to every consumer of `ctx.shell`: the bash tools, hook commands, and background jobs run exactly as before by directly spawning the wrapped argv the provider returns. Deleting the `sandbox` and `permission` entries and replacing `bash` with `@deepseek-ai/dsh-bash-local` is the opt-out — execution is unconfined again and the escalation fields vanish from the tool schema, because they are capability-gated on the mounted executor, not on configuration. Omitting only `approval` keeps confinement but fails every escalation closed with its own error text; `permission` also requires the approval seam and a confining executor, so a partially composed preset layer fails loud at load.
 
-Misconfiguration fails loud: `mode` outside the closed vocabulary is rejected at plugin load, and a host with no usable backend throws the structured `SANDBOX_UNAVAILABLE` at `confine()` rather than degrading to unconfined execution. If the selected runner rejects with attributable `ENOENT` or `EACCES`, the consumer reports the same infrastructure error from the spawn channel before any command starts; other spawn errors retain local command-start semantics while still running nothing. `runnerCommand` on `dsh-sandbox-local` is the operator's explicit assertion of a bwrap-compatible runner (chain and probes skipped); it doubles as the deterministic fake-runner hook for keyless tests.
+Misconfiguration fails loud: `mode` outside the closed vocabulary is rejected at plugin load, and a host with no usable backend throws the structured `SANDBOX_UNAVAILABLE` at `confine()` rather than degrading to unconfined execution. An attributable `ENOENT` or `EACCES` that names the selected runner proves that executable did not start, so the consumer reports the same infrastructure error; other synchronous creation errors propagate unchanged. An asynchronous subprocess-provider rejection exposes no public target stage and retains stage-neutral local semantics. `runnerCommand` on `dsh-sandbox-local` is the operator's explicit assertion of a bwrap-compatible runner (chain and probes skipped); it doubles as the deterministic fake-runner hook for keyless tests.
 
 Denied file effects return a `[sandbox: file access denied under <mode> mode]` marker and instructions not to work around the denial. A confining executor adds paired `sandbox_permissions` and `justification` fields for one approved retry that must be strictly wider than the session's effective mode. A grant widens only that retry; rejection executes nothing, returns `the user rejected escalating this command to "<mode>"`, and permits no re-ask. The owner-derived pending policy context states the current file policy without replacing those enforcement boundaries. When `dsh-permission-presets` is composed with a UI adapter, one preset selects both knob values; unmatched values fold to `custom`. The [ACP application bundle](../../../../packages/bundle/acp-app/README.md) does not mount that UI service and selects its deployment mode explicitly.
 
@@ -70,7 +70,7 @@ Backend profiles share the mode contract but differ in necessary host grants. La
 
 #### The bash consumer
 
-`dsh-bash-sandbox` extends `LocalBashExecutor`, hands `ctx.sandbox` the exact `['bash', '-c', command]` argv, and directly spawns the provider result. This leaves shell semantics and `BASH_ENV` on the inner Bash after the shipped native runner establishes confinement. A provider error propagates unchanged. A pre-process rejection counts as a runner failure only when the caller-owned workdir is independently usable and Node reports `ENOENT` or `EACCES` with either an `error.path` equal to provider argv[0] or, when `error.path` is absent, an exact `syscall: 'spawn <runner>'`; a present path also requires `syscall: 'spawn'` or the exact `spawn <runner>`. Other codes, invalid workdirs, resource failures, unrelated syscalls, and unstructured rejections retain local command-start semantics. Foreground execution converts runner failures to `SANDBOX_UNAVAILABLE` with the original detail; an asynchronous background rejection stamps `runnerFailed: true`, `denied: false`. A `SubprocessRuntime` that synchronously throws the same runner-identifying shape makes background start throw `SANDBOX_UNAVAILABLE`, while other synchronous errors propagate unchanged. After a process starts, foreground and background use one runner-failure classifier that requires the rule's exit-code check and a remaining fatal line after informational exclusions. A match takes priority over denial: foreground execution throws `SANDBOX_UNAVAILABLE` with that fatal line as detail; a settled `ShellProcess` stamps `sandbox.runnerFailed`, and the bash producer renders it through generic `job_output`.
+`dsh-bash-sandbox` extends `LocalBashExecutor`, hands `ctx.sandbox` the exact `['bash', '-c', command]` argv, and directly spawns the provider result. This leaves shell semantics and `BASH_ENV` on the inner Bash after the shipped native runner establishes confinement. A provider error propagates unchanged. An asynchronous rejection counts as a runner failure only when the caller-owned workdir is independently usable and Node reports `ENOENT` or `EACCES` with either an `error.path` equal to provider argv[0] or, when `error.path` is absent, an exact `syscall: 'spawn <runner>'`; a present path also requires `syscall: 'spawn'` or the exact `spawn <runner>`. Other codes, invalid workdirs, resource failures, unrelated syscalls, and unstructured rejections retain stage-neutral provider-failure semantics. Foreground execution converts attributable runner failures to `SANDBOX_UNAVAILABLE` with the original detail; an attributable asynchronous background rejection stamps `runnerFailed: true`, `denied: false`. A `SubprocessRuntime` that synchronously throws the same runner-identifying shape makes background start throw `SANDBOX_UNAVAILABLE`, while other synchronous errors propagate unchanged. After a direct outcome exists, foreground and background use one runner-failure classifier that requires the rule's exit-code check and a remaining fatal line after informational exclusions. A match takes priority over denial: foreground execution throws `SANDBOX_UNAVAILABLE` with that fatal line as detail; a settled `ShellProcess` stamps `sandbox.runnerFailed`, and the bash producer renders it through generic `job_output`.
 
 The model sees the current effective file policy in the owner-derived `sandbox:policy` context, while the static tool description explains the denial marker (`[sandbox: file access denied under <mode> mode]`), encourages attempting commands that may be denied, and forbids retrying around a denial; when the escalation fields are advertised, a denied result additionally carries the escalation hint itself, so the sanctioned same-turn retry is prompted at the decision point rather than depending on the model recalling the description (§ Escalation). [The current-policy decision](../../archived/feature/2026-07-30-current-sandbox-policy-context.md) owns the context's rationale and boundaries.
 
@@ -184,7 +184,7 @@ Costs and accepted limits:
 ## FAQ
 
 - **A command came back with `[sandbox: file access denied under read-only mode]` — did it fail?** It RAN, and the kernel refused a file effect: the denial is a result fact orthogonal to exit code. The teaching forbids retrying around it; the one sanctioned move is the same command retried once with an escalation request.
-- **How is a BROKEN sandbox told apart from a failing command?** Any provider-argv spawn rejection proves the confined launch never started, but it identifies a broken runner only when the caller-owned workdir is usable and Node reports attributable `ENOENT` or `EACCES` for that argv[0]. A bare `syscall: 'spawn'` without an exact error path and all other rejections remain ordinary command-start errors. After a process starts, runner failure outranks denial only when one `runnerFailureRules` entry matches both its optional exit-code gate and a fatal stderr line after exact informational exclusions. Foreground failures throw structured `SANDBOX_UNAVAILABLE` with spawn or matched-line detail; an asynchronously rejected or settled background job stamps `sandbox.runnerFailed` and renders its own marker. A `SubprocessRuntime` that synchronously throws the same `ENOENT`/`EACCES` shape with the runner path makes background start throw the structured error; other synchronous errors propagate unchanged. A Landlock partial-enforcement notice plus an ordinary child failure remains a command result.
+- **How is a BROKEN sandbox told apart from a failing command?** An asynchronous subprocess-provider rejection exposes no public execution stage. It identifies a broken confinement runner only when the caller-owned workdir is usable and Node reports attributable `ENOENT` or `EACCES` for that argv[0]; a bare `syscall: 'spawn'` without an exact error path and all other rejections remain stage-neutral provider failures. After a direct outcome exists, runner failure outranks denial only when one `runnerFailureRules` entry matches both its optional exit-code gate and a fatal stderr line after exact informational exclusions. Foreground runner failures throw structured `SANDBOX_UNAVAILABLE` with executable or matched-line detail; an attributable asynchronous background rejection or a matched settled failure stamps `sandbox.runnerFailed`, while every asynchronous rejection renders the local executor's provider-failure note. A `SubprocessRuntime` that synchronously throws the same `ENOENT`/`EACCES` shape with the runner path makes background start throw the structured error; other synchronous errors propagate unchanged. A Landlock partial-enforcement notice plus an ordinary child failure remains a command result.
 - **What happens on a platform with no backend?** `confine()` throws the fail-closed `SANDBOX_UNAVAILABLE`, and the command never spawns.
 - **`bwrap` is installed on my host but unusable (disabled unprivileged userns, an LSM denying `mount`) — what happens?** The chain probe is functional — it builds and enforces a real profile rather than checking `--version` — so a present-but-unusable `bwrap` fails its probe, selection falls to the packaged Landlock launcher, and the verdict is cached for the provider's lifetime.
 - **Does the sandbox restrict network or process visibility?** `SandboxMode` claims FILE effects only, and no backend claims network. Process visibility is backend-specific: bwrap unshares PID and mounts matching procfs because host `/proc/<pid>` magic links otherwise bypass file confinement, while Landlock and Seatbelt leave process visibility unchanged ([decision](../bug-fix/2026-08-06-bwrap-private-pid-namespace.md)). Whether network restriction becomes its own knob is left open in § The seam.

+ 3 - 3
.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md

@@ -38,7 +38,7 @@ harness 是一个 SDK,因此约束必须是开发者可组合的能力:是
 
 这一替换对 `ctx.shell` 的所有消费方透明:bash 工具、钩子命令和后台任务照常运行,直接使用提供方返回的已包装 argv 启动。删除 `sandbox` 和 `permission` 条目、将 `bash` 替换为 `@deepseek-ai/dsh-bash-local` 即为退出——执行恢复为无约束,升级字段从工具 schema 中消失,因为它们是基于已挂载执行器的能力门控,而非基于配置。仅省略 `approval` 则保留约束但以自身错误文本关闭每次升级;`permission` 还要求 approval seam 和约束执行器同时存在,因此组合不完整的 preset 层会在加载时明确报错。
 
-配置错误会显式导致失败:`mode` 不在封闭词汇中时在插件加载时被拒绝;主机上没有可用后端时在 `confine()` 阶段抛出结构化的 `SANDBOX_UNAVAILABLE`,而非降级为无约束执行。如果所选 runner 以可归因的 `ENOENT` 或 `EACCES` 拒绝,消费方会在任何命令开始前通过 spawn 通道报告同一基础设施错误;其他 spawn 错误仍保留本地命令启动语义,同时也不会运行任何内容。`dsh-sandbox-local` 上的 `runnerCommand` 是运维人员对一个 bwrap 兼容 runner 的显式断言(跳过链和探测);它同时充当 keyless 测试的确定性 fake-runner 钩子。
+配置错误会显式导致失败:`mode` 不在封闭词汇中时在插件加载时被拒绝;主机上没有可用后端时在 `confine()` 阶段抛出结构化的 `SANDBOX_UNAVAILABLE`,而非降级为无约束执行。若可归因的 `ENOENT` 或 `EACCES` 指明所选 runner,就能证明该 executable 未启动,因此消费方会报告同一基础设施错误;其他同步创建错误原样传播。异步 subprocess-provider rejection 不公开 target 阶段,保留本地不声明阶段的语义。`dsh-sandbox-local` 上的 `runnerCommand` 是运维人员对一个 bwrap 兼容 runner 的显式断言(跳过链和探测);它同时充当 keyless 测试的确定性 fake-runner 钩子。
 
 被拒绝的文件操作返回 `[sandbox: file access denied under <mode> mode]` 标记,并附带不要绕过拒绝的指令。约束执行器添加配对的 `sandbox_permissions` 和 `justification` 字段,用于一次经批准的重试,该重试必须严格宽于会话的有效模式。授权仅放宽该次重试;拒绝则不执行任何内容,返回 `the user rejected escalating this command to "<mode>"`,且不允许再次请求。由归属方派生的待处理策略上下文会说明当前文件策略,但不会取代这些强制执行边界。当 `dsh-permission-presets` 与某个 UI 适配器一起组合时,一个 preset 同时选定两个旋钮值;不匹配的组合折叠为 `custom`。[ACP(Agent Client Protocol)应用组合包](../../../../packages/bundle/acp-app/README.zh.md)不挂载该 UI 服务,而是显式选定其部署模式。
 
@@ -70,7 +70,7 @@ Landlock launcher 源码和包家族位于 `native/landlock-run`,与 harness 
 
 #### bash 消费方
 
-`dsh-bash-sandbox` 扩展 `LocalBashExecutor`,把精确的 `['bash', '-c', command]` argv 交给 `ctx.sandbox`,并直接 spawn 提供方返回的 argv。这样,随附的原生 runner 建立约束后,shell 语义与 `BASH_ENV` 仍由内层 Bash 处理。提供方错误原样传播。进程启动前,只有当调用方拥有的 workdir 经独立验证可用,Node 报告 `ENOENT` 或 `EACCES`,并且错误符合以下一种形态时,才判定为 runner 失败:`error.path` 等于提供方返回的 `argv[0]`,同时 `syscall` 为 `'spawn'` 或精确的 `'spawn <runner>'`;或者 `error.path` 不存在,同时 `syscall` 为精确的 `'spawn <runner>'`。其他错误码、无效 workdir、资源失败、无关 syscall 与无结构拒绝保留本地命令启动语义。前台执行会将 runner 失败转为 `SANDBOX_UNAVAILABLE` 并附上原始详细信息;异步后台拒绝则盖章 `runnerFailed: true`、`denied: false`。如果 `SubprocessRuntime` 同步抛出同样能指明 runner 的形态,后台启动会抛出 `SANDBOX_UNAVAILABLE`;其他同步错误原样传播。进程启动后,前台与后台共用一个 runner 失败分类器:先排除信息性行,再要求规则的退出码检查与余下的一行致命诊断同时匹配。匹配结果优先于拒绝:前台执行抛出 `SANDBOX_UNAVAILABLE`,并以该致命行作为详细信息;结算后的 `ShellProcess` 会盖章 `sandbox.runnerFailed`,bash 生产者再通过通用 `job_output` 渲染它。
+`dsh-bash-sandbox` 扩展 `LocalBashExecutor`,把精确的 `['bash', '-c', command]` argv 交给 `ctx.sandbox`,并直接 spawn 提供方返回的 argv。这样,随附的原生 runner 建立约束后,shell 语义与 `BASH_ENV` 仍由内层 Bash 处理。提供方错误原样传播。异步 rejection 只有在调用方拥有的 workdir 经独立验证可用,Node 报告 `ENOENT` 或 `EACCES`,并且错误符合以下一种形态时,才判定为 runner 失败:`error.path` 等于提供方返回的 `argv[0]`,同时 `syscall` 为 `'spawn'` 或精确的 `'spawn <runner>'`;或者 `error.path` 不存在,同时 `syscall` 为精确的 `'spawn <runner>'`。其他错误码、无效 workdir、资源失败、无关 syscall 与无结构 rejection 保留不声明阶段的 provider-failure 语义。前台执行会将可归因的 runner failure 转为 `SANDBOX_UNAVAILABLE` 并附上原始详情;可归因的异步后台 rejection 则盖章 `runnerFailed: true`、`denied: false`。如果 `SubprocessRuntime` 同步抛出同样能指明 runner 的形态,后台启动会抛出 `SANDBOX_UNAVAILABLE`;其他同步错误原样传播。direct outcome 已存在后,前台与后台共用一个 runner failure 分类器:先排除信息性行,再要求规则的退出码检查与余下的一行致命诊断同时匹配。匹配结果优先于拒绝:前台执行抛出 `SANDBOX_UNAVAILABLE`,并以该致命行作为详;结算后的 `ShellProcess` 会盖章 `sandbox.runnerFailed`,bash 生产者再通过通用 `job_output` 渲染它。
 
 模型会在归属方派生的 `sandbox:policy` 上下文中看到当前有效的文件策略;静态工具描述则解释拒绝标记(`[sandbox: file access denied under <mode> mode]`),鼓励尝试可能被拒绝的命令,并禁止绕过拒绝重试。当升级字段被公布时,被拒绝的结果还会携带升级提示本身,使被认可的同轮次重试在决策点获得提示,而非依赖模型回忆描述(§ 升级机制)。[当前策略决策](../../archived/feature/2026-07-30-current-sandbox-policy-context.md)负责该上下文的理由与边界。
 
@@ -184,7 +184,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自能力边
 ## FAQ
 
 - **一个命令返回了 `[sandbox: file access denied under read-only mode]`——它失败了吗?** 它运行了,内核拒绝了一个文件操作:拒绝是与退出码正交的结果事实。相关指令禁止通过绕过限制来重试;唯一被认可的动作是以升级请求重试同一命令一次。
-- **如何区分损坏的沙箱与失败的命令?** 提供方 argv 的任何 spawn 拒绝都能证明受限启动从未开始,但只有在调用方拥有的 workdir 可用,且 Node 为该 argv[0] 报告可归因的 `ENOENT` 或 `EACCES` 时,才能据此判定 runner 损坏。没有精确错误路径的裸 `syscall: 'spawn'` 和其他所有拒绝仍是普通的命令启动错误。进程启动后,只有当 `runnerFailureRules` 中某一条目同时匹配其可选退出码门控,以及排除整行精确信息性行后的一行致命 stderr 诊断时,runner 失败才会优先于拒绝。前台失败会抛出结构化的 `SANDBOX_UNAVAILABLE`,并附带 spawn 错误或匹配行作为详细信息;遭异步拒绝或已结算的后台任务则盖章 `sandbox.runnerFailed` 并渲染自己的标记。如果 `SubprocessRuntime` 同步抛出同样带有 runner 路径的 `ENOENT`/`EACCES` 形态,后台启动会抛出该结构化错误;其他同步错误原样传播。Landlock 部分强制执行通知加上普通子进程失败时,仍返回命令结果。
+- **如何区分损坏的沙箱与失败的命令?** 异步 subprocess-provider rejection 不公开执行阶段。只有在调用方拥有的 workdir 可用,且 Node 为该 argv[0] 报告可归因的 `ENOENT` 或 `EACCES` 时,才能据此判定 confinement runner 损坏;没有精确错误路径的裸 `syscall: 'spawn'` 和其他所有 rejection 都保持不声明阶段的 provider failure。direct outcome 已存在后,只有当 `runnerFailureRules` 中某一条目同时匹配其可选退出码门控,以及排除整行精确信息性行后的一行致命 stderr 诊断时,runner 失败才会优先于拒绝。前台 runner failure 会抛出结构化的 `SANDBOX_UNAVAILABLE`,并附带 executable 或匹配行详情;可归因的异步后台 rejection 或匹配到的已结算失败会盖章 `sandbox.runnerFailed`,而所有异步 rejection 都会渲染本地执行器的 provider-failure 提示。如果 `SubprocessRuntime` 同步抛出同样带有 runner 路径的 `ENOENT`/`EACCES` 形态,后台启动会抛出该结构化错误;其他同步错误原样传播。Landlock 部分强制执行通知加上普通子进程失败时,仍返回命令结果。
 - **在没有后端的平台上会发生什么?** `confine()` 抛出失败关闭的 `SANDBOX_UNAVAILABLE`,命令永不 spawn。
 - **`bwrap` 已安装在我的主机上但不可用(禁用了非特权 userns、LSM 拒绝 `mount`)——会发生什么?** 链探测是功能性的——它构建并强制一个真实 profile 而非检查 `--version`——因此存在但不可用的 `bwrap` 探测失败,选择落到已打包的 Landlock launcher,结论在提供方生命周期内缓存。
 - **沙箱限制网络或进程可见性吗?** `SandboxMode` 仅声称文件操作,没有后端声称网络。进程可见性取决于后端:bwrap 会 unshare PID 并挂载匹配的 procfs,因为宿主 `/proc/<pid>` 魔法链接会绕过文件约束;Landlock 与 Seatbelt 则保持进程可见性不变([决策](../bug-fix/2026-08-06-bwrap-private-pid-namespace.zh.md))。网络限制是否成为自己的旋钮留在 § seam 中开放。

+ 2 - 2
.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md
-2026-07-16-persistent-pty-sessions.md: d0bbb230bdb1cf7ab723a3451f0524cc86996209
-2026-07-16-persistent-pty-sessions.zh.md: 359d8e2b6c7f0ea857d0e73349421fc673d5cc3b
+2026-07-16-persistent-pty-sessions.md: d62587ced78be75de121fe907ca258d07a2830a1
+2026-07-16-persistent-pty-sessions.zh.md: a26c3ed852b3a18842585e19ecb2a6994f8d9117

+ 6 - 4
.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md

@@ -45,7 +45,7 @@ A registered `shell` backend constrains how a terminal starts; it does not const
 
 Sandboxing confines local process effects but does not make arbitrary shell input safe: network calls and other external side effects remain governed by deployment policy. Tool descriptions state that PTY sessions are less auditable than one-shot tools and should be used only when persistence or interactive stdin is necessary.
 
-The local subprocess terminal primitive uses only public `node-pty` capabilities: child PID, `data` and `exit` notifications, `write`, and `kill`. It does not assume access to the native master fd or call `waitpid` from TypeScript. Platform process inspectors below that primitive derive foreground process groups and parent/child identity from `/proc` on Linux and `ps` on macOS. The [portable execution-world decision](../architecture/2026-07-28-portable-execution-world-consumers.md) owns this process/consumer split.
+The local subprocess terminal primitive uses only public `node-pty` capabilities: child PID, `data` and `exit` notifications, `write`, and `kill`. It does not assume access to the native master fd or call `waitpid` from TypeScript. On supported Linux hosts the [native-containment owner](../architecture/2026-08-28-subprocess-native-containment.md) starts that same PTY command inside a user-systemd scope without changing its PID, session, controlling terminal, foreground-group, or readiness semantics. Platform process inspectors below the primitive still derive foreground process groups and fallback parent/child identity from `/proc` on Linux and `ps` on macOS. The [portable execution-world decision](../architecture/2026-07-28-portable-execution-world-consumers.md) owns this process/consumer split.
 
 ### Six model-facing tools
 
@@ -92,7 +92,9 @@ Background sends use the existing task completion notice and `job_output` result
 
 ### Process-tree teardown
 
-The subprocess terminal handle owns the top-level terminal process and its session. On close it snapshots transitive descendants by parent PID in children-first order, sends `SIGTERM`, waits, rescans for children forked during shutdown, sends `SIGKILL` to the union, and verifies every non-zombie descendant left the process table before stopping the top-level process. A matching Linux zombie has no executable work and therefore counts as quiescent. Every captured PID includes process-start identity so reuse cannot redirect escalation.
+On supported Linux hosts, the subprocess terminal handle binds the top-level PTY process to its transient user-systemd scope. Before establishment, close sends `SIGTERM` through the direct PTY fallback so the bootstrap cannot continue; after establishment, it signals the scope alone and uses the direct fallback only if scope signalling fails. It waits for the manager to prove that range empty and escalates to `SIGKILL` after the configured grace. Scope membership continues to include descendants that call `setsid` or reparent, while the PTY's direct exit notification remains the terminal outcome.
+
+Fallback hosts retain observational process-session cleanup. The handle snapshots transitive descendants by parent PID in children-first order, sends `SIGTERM`, waits, rescans for children forked during shutdown, sends `SIGKILL` to the union, and verifies every non-zombie descendant left the process table before stopping the top-level process. A matching Linux zombie has no executable work and therefore counts as quiescent. Every captured PID includes process-start identity so reuse cannot redirect escalation.
 
 Teardown reports top-level exit and survivor cleanup independently. The PTY session does not claim success merely because the shell exited: it calls `SubprocessTerminalHandle.terminate()` and awaits whole-session quiescence, propagating a cleanup failure that names survivors. A failed close is not cached forever: the registry and local session clear the fence only when it still names that failed attempt, so a later explicit or lifecycle close retries without disturbing a newer concurrent attempt. Service disposal still clears its backend, reservation, and owner-detacher registries when a close fails.
 
@@ -158,7 +160,7 @@ The package ships concise tool guidance explaining persistent state, owner isola
 
 - Per-file coverage pins owner fencing, concurrent reservations, cancellation during pre-write inspection, unpublished-spawn cancellation and awaited teardown, sandbox-mode change rejection, retriable lifecycle cleanup, readiness tiers, rejection of pre-write stdin waits and delayed earlier prompts, the configured handoff grace holding the idle fallback past one poll and its rejection below `pollIntervalMs`, sanitizer carry state, complete UTF-8 bounds, task integration, schemas, and exact render intents.
 - Subprocess process fixtures cover non-leader and non-main-thread stdin waits, thread-local fd tables, the `/dev/tty` alias, supported kernel ABIs under user-mode emulation, rejection of fd 0 backed by a pipe, zombie quiescence, unreadable process state, unsupported architectures, and other false-positive rejection; macOS inspector logic is injected into the same unit suite.
-- Real `node-pty` and PTY-consumer tests jointly exercise shell state, controlling-terminal input through `/dev/tty`, the exact attribution when process syscalls are readable, its bounded idle fallback when host policy denies them, shared sandbox policy, environment scrubbing, raw-mode foreground `SIGINT`, a TERM-ignoring descendant, and immediate post-disposal quiescence on supported hosts.
+- Real `node-pty` and PTY-consumer tests jointly exercise shell state, controlling-terminal input through `/dev/tty`, the exact attribution when process syscalls are readable, its bounded idle fallback when host policy denies them, shared sandbox policy, environment scrubbing, raw-mode foreground `SIGINT`, a TERM-ignoring descendant, and immediate post-disposal quiescence. The Linux native smoke keeps the PTY PID, session leader, controlling terminal, foreground `inputWaiting`, and readiness while a reparented `setsid` descendant remains owned by the scope; fallback suites retain identity-fenced observational cleanup coverage.
 - A Loader-driven `cordis.yml` test mounts the real three-package composition and verifies that delayed pipeline output returns with the completed command instead of being classified as terminal-input readiness. The SDK minimal snapshot pins that output through the persistent Bash tool; ACP and headless snapshots pin the six terminal schemas, bounded results, and errors through opt-in overlays; TUI snapshots pin terminal and generic card presentation.
 - Package contracts, the architecture map, subsystem pages, generated catalogs, and the website API describe the same shipped surface.
 
@@ -172,7 +174,7 @@ The package ships concise tool guidance explaining persistent state, owner isola
 
 **Persistent state can drift from the model's belief.** The model may forget its cwd or active REPL. Session summaries and retained output help recovery, but no prompt can make state persistence deterministic.
 
-**A daemonized descendant can leave the local provider's captured tree.** A process that reparents before teardown is no longer discoverable from the `node-pty` root. The local terminal primitive accepts that cleanup gap instead of risking SID-wide signals to unrelated processes.
+**Native Linux ownership closes the process-tree observation gap; fallback ownership does not.** A supported user-systemd scope retains a daemonized or reparented descendant as a member until the scope becomes empty. On macOS, Windows ConPTY, and Linux hosts that cannot establish the scope, a process that escapes before observational teardown can still evade the captured tree; the fallback accepts that gap instead of risking SID-wide signals to unrelated processes.
 
 **A shell can cause external side effects.** Session sandboxing and environment scrubbing reduce local exposure but do not undo pushes, API calls, or messages. Deployments that cannot tolerate those effects must omit PTY or add network policy.
 

+ 6 - 4
.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md

@@ -45,7 +45,7 @@ agent scope dispose(资源释放)时先撤销注册,再等待全部所属
 
 沙箱限制本地进程副作用,但不会让任意 shell 输入自动安全:网络调用和其他外部副作用仍由部署策略治理。工具描述会说明 PTY 会话比一次性工具更难审计,只应在确实需要持久状态或交互式 stdin 时使用。
 
-本地子进程终端原语只使用 `node-pty` 的公开能力:子进程 PID、`data` 与 `exit` 通知、`write` 和 `kill`。它不假设能访问原生 master fd,也不从 TypeScript 调用 `waitpid`。该原语下的平台进程检查器在 Linux 上通过 `/proc`、在 macOS 上通过 `ps` 推导前台进程组和父子进程身份。[可移植执行环境决策](../architecture/2026-07-28-portable-execution-world-consumers.zh.md)负责定义这种进程/消费方拆分。
+本地子进程终端原语只使用 `node-pty` 的公开能力:子进程 PID、`data` 与 `exit` 通知、`write` 和 `kill`。它不假设能访问原生 master fd,也不从 TypeScript 调用 `waitpid`。在受支持的 Linux 宿主上,[原生收容 owner](../architecture/2026-08-28-subprocess-native-containment.zh.md)会在 user-systemd scope 内启动同一条 PTY 命令,同时保持 PID、session、控制终端、前台进程组与就绪语义。该原语下的平台进程检查器在 Linux 上通过 `/proc`、在 macOS 上通过 `ps` 推导前台进程组和 fallback 父子进程身份。[可移植执行环境决策](../architecture/2026-07-28-portable-execution-world-consumers.zh.md)负责定义这种进程/消费方拆分。
 
 ### 6 个面向模型的工具
 
@@ -92,7 +92,9 @@ Tier 2 在持续 `idleSilenceMs` 没有输出后返回 `inferred_idle`,因此
 
 ### 进程树 teardown
 
-子进程终端句柄拥有顶层终端进程及其会话。关闭时,它按父 PID 以子进程优先顺序捕获传递后代、发送 `SIGTERM` 并等待,然后重新扫描关停期间 fork 出的子进程,向二者并集发送 `SIGKILL`,并在停止顶层进程前验证每个非僵尸后代都已离开进程表。身份匹配的 Linux 僵尸进程已无可执行工作,因此视为完全停稳。每个捕获的 PID 都包含进程启动身份,避免 PID 复用把升级信号发给无关进程。
+在受支持的 Linux 宿主上,subprocess 终端句柄会把顶层 PTY 进程绑定到临时 user-systemd scope。建立前,close 通过 direct PTY fallback 发送 `SIGTERM`,阻止 bootstrap 继续;建立后只向 scope 发送信号,并且仅在 scope signalling 失败时使用 direct fallback。随后它等待 manager 证明该 range 为空,并在配置的宽限期后升级到 `SIGKILL`。调用 `setsid` 或发生 reparent 的后代仍属于 scope,而 PTY 的 direct exit 通知继续作为终端结果。
+
+fallback 宿主保留观察式进程 session 清理。句柄会按父 PID 以子进程优先顺序捕获传递后代、发送 `SIGTERM` 并等待,然后重新扫描关停期间 fork 出的子进程,向二者并集发送 `SIGKILL`,并在停止顶层进程前验证每个非僵尸后代都已离开进程表。身份匹配的 Linux 僵尸进程已无可执行工作,因此视为完全停稳。每个捕获的 PID 都包含进程启动身份,避免 PID 复用把升级信号发给无关进程。
 
 teardown 独立报告顶层进程退出与存活进程清理。PTY 会话不会只因 shell 退出就声称成功:它会调用 `SubprocessTerminalHandle.terminate()` 并等待整个会话完全停稳,若清理失败则向外传播并列出存活者。失败的 close 不会永久缓存:注册表与本地会话各自仅在关闭围栏仍指向该次失败尝试时才将其清除,因此后续的显式 close 或生命周期 close 会重试,且不会干扰较新的并发尝试。即使某个 close 失败,服务 dispose 仍会清空其后端、预留与 owner detacher 注册表。
 
@@ -158,7 +160,7 @@ plugins:
 
 - 逐文件覆盖测试锁定了 owner 隔离、并发预留、写入前检查期间的取消、未发布 spawn 的取消与等待式 teardown、沙箱模式变更拒绝、可重试的生命周期清理、就绪层级、对写入前 stdin 等待与延迟到达的先前 prompt 的拒绝、配置化交接宽限把 idle fallback 顶过一次轮询以及低于 `pollIntervalMs` 时的拒绝、sanitizer carry state、完整 UTF-8 结果上限、task 集成、schema 和精确 render intent。
 - 子进程 fixture(测试前置数据)覆盖非 leader 与非主线程的 stdin 等待、线程本地 fd 表、`/dev/tty` 别名、用户态模拟下受支持的内核 ABI、拒绝把指向管道的 fd 0 当作终端输入、僵尸进程完全停稳、不可读进程状态、不支持的架构和其他误报拒绝;同一单元测试套件通过注入覆盖 macOS 检查器逻辑。
-- 真实 `node-pty` 与 PTY 消费方测试共同在受支持宿主上覆盖 shell 状态、通过 `/dev/tty` 读取控制终端输入、进程 syscall 可读时的精确归因、宿主策略拒绝读取时的有界 idle fallback、共享沙箱策略、环境清洗、raw mode 前台 `SIGINT`、忽略 `SIGTERM` 的后代进程,以及 dispose 返回后立即完全停稳。
+- 真实 `node-pty` 与 PTY 消费方测试共同覆盖 shell 状态、通过 `/dev/tty` 读取控制终端输入、进程 syscall 可读时的精确归因、宿主策略拒绝读取时的有界 idle fallback、共享沙箱策略、环境清洗、raw mode 前台 `SIGINT`、忽略 `SIGTERM` 的后代进程,以及 dispose 返回后立即完全停稳。Linux native 冒烟测试会在一个 reparent 的 `setsid` 后代仍由 scope 拥有时,保持 PTY PID、session leader、控制终端、前台 `inputWaiting` 与 readiness;fallback 测试套件继续覆盖带身份围栏的观察式清理。
 - Loader 驱动的 `cordis.yml` 测试挂载真实三包组合,并验证延迟到达的流水线输出随已完成命令返回,而不会被归类为终端输入就绪。SDK minimal 快照通过持久 Bash 工具固定该输出;ACP 与 headless 快照通过 opt-in overlay 固定 6 个终端 schema、有界结果和错误;TUI 快照固定 terminal 与 generic 卡片展示。
 - 包约定、架构图、子系统页面、生成目录和 website API 描述同一个已发布接口。
 
@@ -172,7 +174,7 @@ plugins:
 
 **持久状态可能偏离模型认知。**模型可能忘记 cwd 或活跃 REPL。会话摘要和保留输出有助恢复,但任何提示词都无法让状态持久化变成确定行为。
 
-**daemonized 后代进程可能离开本地提供方捕获的进程树。**在 teardown 前 reparent 的进程无法再从 `node-pty` 根进程发现。本地终端原语接受这个清理缺口,不冒险按 SID 向无关进程发送信号。
+**Linux native ownership 消除了进程树观察缺口,fallback ownership 没有。**受支持的 user-systemd scope 会持续保有 daemonized 或 reparent 后代,直到 scope 为空。在 macOS、Windows ConPTY,以及无法建立 scope 的 Linux 宿主上,观察式 teardown 开始前已经逃逸的进程仍可能避开捕获树;fallback 接受这个缺口,不冒险按 SID 向无关进程发送信号。
 
 **Shell 可以造成外部副作用。**会话沙箱和环境清洗降低本地暴露,但无法撤销 push、API 调用或消息发送。无法容忍这些副作用的部署必须省略 PTY 或增加网络策略。
 

+ 6 - 0
.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.i18n.yaml

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

+ 57 - 0
.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.md

@@ -0,0 +1,57 @@
+# Agent Note: Promote open-anywhere from community plugin to first-party package
+
+Status: implemented
+
+English | [中文](2026-08-25-promote-open-anywhere-plugin.zh.md)
+
+## Problem
+
+The community plugin `@dsh-plugins/open-anywhere` (gitlab.deepseek.com/Ciyou/dsh-open-anywhere) adds a Session-header "Open In..." split button that opens the session's workspace directory in Finder, Cursor, VS Code, Xcode, a Git GUI, or a terminal. It shipped as hand-authored `lib/` JavaScript installed through `dsh plugin add`: untyped, untested, calling `node:child_process` directly, hand-rolling its own dropdown and style tag, carrying a browser-side DSH-version gate against rc6–rc8, and probing `process.argv` to guess the running dsh version. Users wanted the feature as a shipped part of the Web profile, which the bundle-install path cannot give it — and the external form violates nearly every repository convention (locale-owned copy, per-file coverage, wire-boundary validation, capability seams for host commands).
+
+## Decision
+
+The first-party feature is named `open-in-app`: it selects the application that opens a workspace directory on the Harness host, not another machine or destination.
+
+The feature's first-party owners are `@deepseek-ai/dsh-host-open-in-app` at `packages/host/open-in-app/` (the probe, catalog, and launch routes) and `@deepseek-ai/dsh-client-ui-open-in-app` at `packages/client/ui-open-in-app/` (the split button), mounted in the Web profile by the `dsh-web-app` bundle rows `open-in-app` and `ui-open-in-app`. The promotion is a rewrite, not a vendoring:
+
+- **A host/client package pair, following the `directory-picker-browse`/`ui-directory-picker-browse` pairing**: the host package's `src/index.ts` registers the three HTTP routes on `ctx.webServer` (`GET /open-in-app/apps`, `GET /open-in-app/icon/<id>`, `POST /open-in-app/open`); the ui package's `src/client/index.ts` registers the split button into `conversation.session.header.utilities` through the standard slot/inject currency, with copy in a typed `open-in-app` locale namespace and styling in CSS Modules over `--dsw-*` tokens (the original's hand-injected style tag and inline dropdown are replaced by the `Menu` primitive), over an empty-apply node half that keeps the plugin on the host roster. Route paths and wire payload types have one home, the host package's browser-safe `./shared` subpath (constants and types only); the client bundle inlines it through an `INLINE_SAFE` entry in the client tsdown preset, the same channel `dsh-session`'s wire slices use. The host root exports only the Loader-required plugin values and types; catalog, resolver, launcher, and icon helpers remain source-internal.
+- **One resolution pass yields verified launchers; a click never re-detects.** The host resolves the whole catalog lazily once per process into a map of catalog id to `OpenInAppResolvedLaunch` — a launcher this host actually holds, never a bare install record. `GET /apps` serves the map's keys and `POST /open` launches its value directly; a launch whose executable is gone (spawn `ENOENT`) invalidates that one entry, re-resolves it once, and drops it from the list when nothing proves it anymore (so uninstalls self-heal while new installs wait for a restart).
+- **Resolution sources are platform-honest and cheap.** macOS checks the known application directories (`/Applications`, `~/Applications`) for the entry's bundle spellings and launches `open -a <resolved bundle>`; Xcode follows `xcode-select -p`. Windows reads `App Paths`, then Uninstall records kept only when they prove an executable on disk, then known paths and the newest versioned install directory where an application uses one — one batched `reg.exe query` per root per pass. GitHub Desktop resolves its versioned executable together with the packaged `cli.js` and invokes the supported `github open <path>` behavior without a command shell. CLI names resolve in-process through `ctx.subprocess.resolveExecutable()` (PATH/PATHEXT stat, no shell, no `which`/`where.exe` children); Linux GUI entries whose CLI is off PATH fall back to their XDG desktop entry's verified `TryExec`/`Exec`, while `xdg-open` is offered only when the host announces a display server. The remaining host commands (`xcode-select`, `reg.exe`, icon extraction) run through `@deepseek-ai/dsh-native-command` (argv, never a shell).
+- **Three independent deadlines replace the single `commandTimeoutMs`**: `probeTimeoutMs` (resolution commands), `iconTimeoutMs` (icon extraction commands), and `launchWatchMs` (the early-failure watch window), so tuning one operation never changes another's response time — the shipped bundle keeps conservative 10 s command deadlines (timeouts are failure bounds, not latency budgets) with a 1 s watch window, which is what bounds how long the open route and the button's busy dress hold a successful launch. Launches spawn detached with a credential-scrubbed environment (`scrubbedParentEnv` from `dsh-subprocess`) plus explicit adapter entries; Windows GUI launchers remain visible unless an adapter explicitly hides its CLI process because that process opens the GUI separately. A launcher still running when the watch window closes counts as launched and is never killed or awaited (kitty and the JetBrains IDEs stay in the foreground for their window lifetime).
+- **Icons extract on every platform with a host source.** macOS converts the resolved bundle's `.icns` to a 128px PNG (`plutil` + `sips`); Windows extracts the resolved executable's associated icon as a 32px PNG through a generated PowerShell script run with positional `-File` args (no command-line path parsing); Linux follows the spec's desktop entry `Icon=` into the hicolor theme and pixmaps directories (PNG or SVG, filesystem only). Any failure answers 404 and the browser keeps its generic glyph.
+- **The launch catalog is a data table with per-platform entries** (`OPEN_IN_APP_CATALOG`): each application id declares, per platform (`darwin`/`win32`/`linux`), a chain of launcher sources tried in order — `fixed` (ships with the OS), `app` (known-directory bundle spellings), `xcode` (`xcode-select -p`), `cli` (in-process PATH resolution), `file` (first existing candidate under `${VAR}`/`~/` expansion), `scan` (newest versioned install directory), `app-paths` and `install-record` (the Windows registry tiers), `github-desktop` (the versioned executable plus packaged CLI), and `desktop` (Linux XDG desktop entries) — plus a launcher argv template (a `{path}` token carries the directory in place, otherwise it is appended), optional environment and Windows visibility policy, and an optional fallback launcher (Xcode's `open -a <bundle>` behind `xed`). The whitelist follows Codex's "Open In" target list: editors and IDEs (VS Code, VS Code Insiders, Cursor, Windsurf, Zed, Sublime Text, Xcode, Android Studio, seven JetBrains IDEs), the promoted plugin's Git GUIs, terminals (Terminal, iTerm2, Ghostty, Warp, kitty, Windows Terminal, Git Bash, GNOME Terminal, Konsole), and per-platform file managers. File managers and platform terminals are separate ids (`finder`/`explorer`/`filemanager`) rather than one id with per-platform labels, because labels are static browser dictionary entries and only one of them probes as available per host. The file-manager entries launch through `dsh-native-command`'s path opener itself (`shell-open`, the OS shell's open verb with the full parent environment, not a detached scrubbed spawn), because a direct `explorer.exe <dir>` spawn does not reliably raise a window. Closed unions end in `assertNever`.
+- **Every route runs behind the composition connection service's trust fence** (`requestRejection`: the Host/Origin fence defeating DNS rebinding and cross-site calls, plus browser authentication), the same guard the API gateway applies to its WebSocket upgrade; the mechanism's one home is the `src/index.ts` module comment. On top of that fence the open route validates the body at the wire: an `application/json` media type (exact essence, not a substring match), a 64 KiB bound with a drained 413, string field types, only probed-available catalog ids, and an absolute path naming an existing directory.
+- **The DSH-version gate is deleted.** It existed because the plugin rode release-to-release against an interface it did not own; a first-party package is versioned with the repository, so the gate, its `sessionStorage`/`localStorage` trust ledger, and the argv-walking version probe have no referent.
+- **The last choice persists through `createSnapshotStore(..., { persist })`** (`dsh.open-in-app.choice`), replacing hand-rolled `localStorage` access. A fresh store has no platform-specific choice; the component uses the first available host entry until the user chooses one.
+
+The pair lives in `packages/host/` and `packages/client/` because that is what the halves are: the probe/launch side is host infrastructure beside the webserver it consumes, and the button is a client surface beside the other `ui-*` packages. Review moved it there from a single dual-half package in `packages/workspace/` (see Alternatives).
+
+## Alternatives considered
+
+**Vendor the plugin's `lib/` as-is under `packages/`.** Fastest, but the hand-authored JavaScript fails typecheck, coverage, i18n, JSDoc, and invariant gates wholesale; keeping it exempt would create a package class the repository deliberately does not have.
+
+**A Typert Remote instead of raw webServer routes.** The apps/open calls fit the Remote RPC shape, but the icon route serves binary PNGs, which the JSON RPC vocabulary does not carry; splitting icons onto a raw route while apps/open ride Remote gives two transports for one feature. Raw routes also match the original's client, and `webhook-github` establishes the validated-raw-route pattern.
+
+**Extend `host/apiproxy`'s `openPath` instead of a new open endpoint.** `openPath` opens one path with the OS-default application; this feature's subject is *which* application, with availability probing and per-application launchers — a different contract. Both share `dsh-native-command`.
+
+**One dual-half package in `packages/workspace/` (the shape that shipped first, following `dsh-session-log-export`).** Split during review into the host/client pair: the workspace group's contract is host-side only, the feature consumes `webServer` rather than `workspaceRegistry`, and registering the single package on the Client compiler aggregate forced the host route and catalog tests to pose as `.client.spec.ts`. The split puts each half's tests on its own compiler face and its dependencies in the right sections; the wire contract stayed in one home via the host package's `./shared` subpath.
+
+**A configurable catalog (cordis.yml-defined applications).** Deferred: each entry couples discovery, launch arguments, process policy, and icon behavior, so a user-facing settings owner and validation rules are required before accepting arbitrary commands. User-supplied labels are user data and do not conflict with locale-owned product copy. Codex and Orca demonstrate the likely extension: maintained built-in presets plus configurable custom handlers.
+
+**Enumerating every installed application through operating-system APIs.** Rejected as the menu's authority: Launch Services, Windows registration data, and XDG desktop entries can locate applications, but they do not establish which applications accept a workspace directory or which launch protocol opens it correctly. OS-native identifiers remain useful locator inputs for maintained presets; custom handlers cover the long tail without guessing launch semantics.
+
+**Shipping static icons for Windows/Linux entries (Codex bundles PNGs per target).** Rejected: the icon route serves real host icons on all three platforms, and bundling third-party product artwork adds an asset pipeline and trademark surface for cosmetic gain.
+
+**Subprocess-heavy detection (the shape that shipped first): `open -Ra` per macOS entry, `which`/`where.exe` per CLI, and a re-resolution on every launch.** Replaced during review: a list resolution spawned ~26 children on macOS, display-name Launch Services queries are weaker evidence than an on-disk bundle, the repository already carries in-process PATH resolution (`ctx.subprocess.resolveExecutable()`), and re-detecting on click put the probe deadline on the interactive path. A batched-`mdfind` fallback for relocated macOS bundles was also considered and left out with the review's known-paths instruction; the miss is recorded as a Known Limitation. A native LaunchServices/NSWorkspace lookup would need an addon the repository does not carry — deferred, with known `.app` paths as the stand-in.
+
+**A public application-discovery Service Definition.** Deferred on the single-consumer rule: the resolver stays a package-internal module until a second GUI discovery consumer exists.
+
+**128px Windows icons through an `SHDefExtractIcon` P/Invoke (`Add-Type`) script.** Deferred: `ExtractAssociatedIcon` is the stock .NET surface with no compiled snippet, and 32px only softens slightly at the button's 15-18 CSS px on high-DPI displays; the P/Invoke variant is a script-local upgrade if that softness matters in practice. Following the user's active Linux icon theme was likewise left out — hicolor is the freedesktop fallback every theme inherits from — so themed desktops may see the stock icon.
+
+## Consequences
+
+- The Web profile gains the header button wherever the host probes at least one installed catalog application on macOS, Windows, or Linux, with zero rendering elsewhere (empty probed catalog → the component returns null).
+- The community plugin's install path remains valid but redundant; its original routes and browser choice key are separate from `open-in-app`, so installations using the first-party feature should remove the community plugin to avoid duplicate header controls.
+- Resolution and icons run lazily, once per host process, so an application installed while dsh runs appears only after restart — accepted; the uninstall direction self-heals through the `ENOENT` single-entry refresh.
+- The catalog is compile-time fixed; extending it means editing `OPEN_IN_APP_CATALOG` and both locale dictionaries together (README Known Limitations). Platform coverage is uneven — several Git GUIs and terminals are macOS-only entries, Windows icons are limited to the 32px stock .NET extraction, Linux follows hicolor rather than the active theme, and CLI-only entries without a desktop record keep the generic icon.
+- Coverage: resolver logic (every locator kind over temp filesystems, registry-dump and desktop-entry fixtures, an injected env/home/PATH table), per-platform icon extraction, the three routes (real Loader + real WebServer composition, including the one-pass cache, the `ENOENT` refresh, and HMR-safety disposal), controller wire behavior, and component presentation are unit-tested to the per-file 100% gate; no snapshot is added because the shipped keyless snapshot fixtures assert session-driven output, which this browser-side control never touches. The web ARIA goldens disable the `open-in-app` and `ui-open-in-app` rows, and the Host-only preset e2e composition disables the host row: the button reflects whatever applications the running machine has installed, so its presence and label are host facts no cross-platform golden can pin.

+ 57 - 0
.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.zh.md

@@ -0,0 +1,57 @@
+# Agent Note: 将 open-anywhere 从社区插件转正为第一方包
+
+Status: implemented
+
+[English](2026-08-25-promote-open-anywhere-plugin.md) | 中文
+
+## 问题
+
+社区插件 `@dsh-plugins/open-anywhere`(gitlab.deepseek.com/Ciyou/dsh-open-anywhere)在会话头部增加一个 "Open In..." 分体按钮,可在 Finder、Cursor、VS Code、Xcode、Git GUI 或终端中打开会话的 workspace 目录。它以手写 `lib/` JavaScript 形式经 `dsh plugin add` 安装:无类型、无测试、直接调用 `node:child_process`、手搓下拉菜单和 style 标签、自带针对 rc6–rc8 的浏览器端 DSH 版本门禁,并靠探测 `process.argv` 猜测运行中的 dsh 版本。用户希望该功能成为 Web profile 的内置部分,而 bundle 安装路径给不了这一点——且外部形态几乎违反了仓库的所有约定(locale 拥有文案、逐文件覆盖率、wire 边界校验、主机命令的能力接缝)。
+
+## 决定
+
+第一方功能命名为 `open-in-app`:它选择在 Harness 主机上打开 workspace 目录的应用,不表示另一台机器或目的位置。
+
+该功能的第一方归属是一对包:`@deepseek-ai/dsh-host-open-in-app` 位于 `packages/host/open-in-app/`(探测、目录与启动路由),`@deepseek-ai/dsh-client-ui-open-in-app` 位于 `packages/client/ui-open-in-app/`(分体按钮),由 `dsh-web-app` bundle 的 `open-in-app` 与 `ui-open-in-app` 两行挂载进 Web profile。转正是重写,不是 vendoring:
+
+- **一对 host/client 包,沿用 `directory-picker-browse`/`ui-directory-picker-browse` 的配对结构**:host 包的 `src/index.ts` 在 `ctx.webServer` 上注册三条 HTTP 路由(`GET /open-in-app/apps`、`GET /open-in-app/icon/<id>`、`POST /open-in-app/open`);ui 包的 `src/client/index.ts` 经标准 slot/inject 通货把分体按钮注册进 `conversation.session.header.utilities`,文案在类型化的 `open-in-app` locale 命名空间中,样式为 `--dsw-*` token 上的 CSS Modules(原插件手工注入的 style 标签与内联下拉被 `Menu` 原语替代),节点半边是让插件出现在主机名册上的空 apply。路由路径与 wire 载荷类型只有一个家:host 包浏览器安全的 `./shared` 子路径(只有常量与类型);client bundle 经 client tsdown preset 的 `INLINE_SAFE` 条目将其内联,与 `dsh-session` 各 wire 切片同一通道。host 根入口只导出 Loader 所需的插件实体与类型;目录、resolver、launcher 与图标 helper 保持源码内部可见。
+- **一趟解析产出已验证的启动器;点击绝不重新检测。** 主机把整个目录每进程惰性解析一次,产出目录 id 到 `OpenInAppResolvedLaunch` 的映射——本机实际持有的启动器,绝不是裸的安装记录。`GET /apps` 提供映射的 keys,`POST /open` 直接启动其值;启动时发现可执行文件已消失(spawn `ENOENT`)会只作废该条目、重解析一次,无法再证明时把它从列表移除(卸载自愈,新安装等重启)。
+- **解析来源按平台务实且廉价。** macOS 在已知应用目录(`/Applications`、`~/Applications`)查条目的 bundle 拼写,启动 `open -a <解析出的 bundle>`;Xcode 跟随 `xcode-select -p`。Windows 依次读 `App Paths`、只在能证明磁盘可执行文件时采用的 Uninstall 记录、已知路径,以及采用版本化安装目录的应用中最新的目录——每趟每根一条批量 `reg.exe query`。GitHub Desktop 会同时解析版本化可执行文件与随包提供的 `cli.js`,不经命令 shell 调用受支持的 `github open <path>` 行为。CLI 名称经 `ctx.subprocess.resolveExecutable()` 进程内解析(PATH/PATHEXT stat,无 shell、无 `which`/`where.exe` 子进程);CLI 不在 PATH 上的 Linux GUI 条目回退到其 XDG desktop 条目验证过的 `TryExec`/`Exec`,且只有主机声明了 display server 时才提供 `xdg-open`。其余主机命令(`xcode-select`、`reg.exe`、图标提取)经 `@deepseek-ai/dsh-native-command`(argv,绝不走 shell)执行。
+- **三个独立期限取代单一 `commandTimeoutMs`**:`probeTimeoutMs`(解析命令)、`iconTimeoutMs`(图标提取命令)、`launchWatchMs`(早期失败看护窗口),调整一种操作的超时不再改变其他操作的响应时间——随发行 bundle 保守地保留 10 秒命令期限(超时是失败上界而非延迟预算),看护窗口 1 秒,它才是约束 open 路由与按钮忙碌态挂起一次成功启动时长的量。启动以清理过凭据的环境(`dsh-subprocess` 的 `scrubbedParentEnv`)叠加适配器显式环境后 detached 派生;Windows GUI launcher 默认保持可见,只有负责另行打开 GUI 的 CLI 适配器会显式隐藏自己的进程。看护窗口关闭时仍在运行的启动器计为已启动,绝不会被杀死或等待(kitty 与 JetBrains IDE 在整个窗口生命周期内保持前台)。
+- **有主机来源的平台都提取图标。** macOS 把解析出的 bundle 的 `.icns` 转 128px PNG(`plutil` + `sips`);Windows 用生成的 PowerShell 脚本以位置式 `-File` 参数(路径不经命令行解析)提取解析出的可执行文件的关联图标为 32px PNG;Linux 沿 spec 的 desktop 条目 `Icon=` 查 hicolor 主题与 pixmaps 目录(PNG 或 SVG,纯文件系统)。任何失败应答 404,浏览器保持通用占位图形。
+- **启动目录是按平台声明条目的数据表**(`OPEN_IN_APP_CATALOG`):每个应用 id 按平台(`darwin`/`win32`/`linux`)声明一条按序尝试的启动器来源链——`fixed`(随系统内置)、`app`(已知目录的 bundle 拼写)、`xcode`(`xcode-select -p`)、`cli`(进程内 PATH 解析)、`file`(`${VAR}`/`~/` 展开后第一个存在的候选文件)、`scan`(带版本号安装目录取最新)、`app-paths` 与 `install-record`(Windows 注册表两层)、`github-desktop`(版本化可执行文件与随包 CLI)、`desktop`(Linux XDG desktop 条目)——加启动器 argv 模板(`{path}` token 原位携带目录,否则目录追加在末尾)、可选环境与 Windows 可见性策略,以及可选回退启动器(`xed` 之后的 `open -a <bundle>`)。白名单对齐 Codex 的 "Open In" 目标列表:编辑器与 IDE(VS Code、VS Code Insiders、Cursor、Windsurf、Zed、Sublime Text、Xcode、Android Studio、七个 JetBrains IDE)、转正插件原有的 Git GUI、终端(Terminal、iTerm2、Ghostty、Warp、kitty、Windows Terminal、Git Bash、GNOME Terminal、Konsole)与各平台文件管理器。文件管理器与平台终端使用独立 id(`finder`/`explorer`/`filemanager`),而非一个 id 配平台标签,因为标签是静态浏览器词典条目,且每台主机只会探测到其中一个。文件管理器条目直接经 `dsh-native-command` 的路径打开器启动(`shell-open`,OS shell 的 open verb,携带完整父环境,而非 detached 的清理环境 spawn),因为直接 spawn `explorer.exe <dir>` 不能可靠地弹出窗口。封闭 union 以 `assertNever` 收尾。
+- **所有路由都运行在组合 connection 服务的信任栅栏之后**(`requestRejection`:挫败 DNS rebinding 与跨站调用的 Host/Origin 栅栏,加上浏览器认证),与 API gateway 施加在其 WebSocket upgrade 上的守卫相同;机制的唯一出处是 `src/index.ts` 的模块注释。在该栅栏之上,open 路由在 wire 边界校验请求体:`application/json` 媒体类型(精确 essence,而非子串匹配)、以排空后 413 的方式把 body 限制在 64 KiB、校验字段类型、只接受探测为可用的目录 id,并要求指向现存目录的绝对路径。
+- **删除了 DSH 版本门禁。** 它存在是因为插件逐版本骑乘一个它不拥有的接口;第一方包与仓库同版本发布,门禁、它的 `sessionStorage`/`localStorage` 信任台账和 argv 遍历版本探测都失去了所指。
+- **上次选择经 `createSnapshotStore(..., { persist })` 持久化**(`dsh.open-in-app.choice`),替代手写 `localStorage` 访问。新存储没有平台特定的初始选择;用户首次选择前,组件使用主机提供的第一个可用条目。
+
+这对包放在 `packages/host/` 与 `packages/client/`,因为两个半边本来就是这两种东西:探测/启动侧是主机基础设施,与它消费的 webserver 同组;按钮是客户端表面,与其他 `ui-*` 包同组。评审把它从 `packages/workspace/` 的单个双半边包迁到这里(见替代方案)。
+
+## 考虑过的替代方案
+
+**将插件的 `lib/` 原样 vendor 进 `packages/`。** 最快,但手写 JavaScript 会整体不过 typecheck、覆盖率、i18n、JSDoc 和 invariant 门禁;为其保留豁免会造出仓库刻意不设的包类别。
+
+**用 Typert Remote 而非裸 webServer 路由。** apps/open 调用符合 Remote RPC 形态,但 icon 路由提供二进制 PNG,JSON RPC 词汇承载不了;把 icon 拆去裸路由而 apps/open 走 Remote 会让一个功能有两种传输。裸路由也匹配原插件的客户端,且 `webhook-github` 已确立带校验裸路由的先例。
+
+**扩展 `host/apiproxy` 的 `openPath` 而非新 open 端点。** `openPath` 用系统默认应用打开一个路径;本功能的主体是*用哪个*应用,带可用性探测和逐应用启动器——是不同的契约。两者共享 `dsh-native-command`。
+
+**放在 `packages/workspace/` 的单个双半边包(最初交付的形态,沿用 `dsh-session-log-export`)。** 评审期拆成 host/client 对:workspace 组的契约是 host-side only,该功能消费的是 `webServer` 而非 `workspaceRegistry`,且单包整体注册在 Client 编译聚合面迫使主机路由与目录测试伪装成 `.client.spec.ts`。拆分让每个半边的测试落在自己的编译面、依赖落在正确的区段;wire 契约经 host 包的 `./shared` 子路径保持唯一出处。
+
+**可配置目录(cordis.yml 定义应用)。** 延后:每个条目耦合发现、启动参数、进程策略与图标行为,因此接受任意命令前需要明确的用户设置归属与校验规则。用户提供的 label 属于用户数据,不与 locale 拥有的产品文案冲突。Codex 与 Orca 展示了可能的扩展形态:维护过的内置 preset 加可配置 custom handler。
+
+**通过操作系统 API 枚举所有已安装应用。** 不作为菜单真源:Launch Services、Windows 注册信息与 XDG desktop 条目可以定位应用,但不能证明每个应用都能接收 workspace 目录,也不能给出正确打开目录所需的启动协议。OS 原生标识仍可作为维护过的 preset 的 locator 输入;custom handler 用于覆盖长尾,而不是猜测启动语义。
+
+**为 Windows/Linux 条目内置静态图标(Codex 为每个目标打包 PNG)。** 拒绝:图标路由在三个平台都提供主机真实图标,为装饰性收益打包第三方产品图形会引入资产管线与商标风险面。
+
+**重子进程检测(最初交付的形态):macOS 每条目一次 `open -Ra`、CLI 各一次 `which`/`where.exe`、每次启动重新解析。** 评审期替换:一次列表解析在 macOS 上派生约 26 个子进程,按显示名查 Launch Services 的证据弱于磁盘上的 bundle,仓库已有进程内 PATH 解析(`ctx.subprocess.resolveExecutable()`),且点击时重新检测把探测期限放上了交互路径。为挪位 macOS bundle 考虑过批量 `mdfind` 兜底,依评审的 known-paths 指示未采用;漏检记入已知限制。原生 LaunchServices/NSWorkspace 查询需要仓库尚无的 addon——延后,以已知 `.app` 路径为替身。
+
+**公共的应用发现 Service Definition。** 按单消费者规则延后:在出现第二个 GUI 发现消费者之前,resolver 保持为包内模块。
+
+**经 `SHDefExtractIcon` P/Invoke(`Add-Type`)脚本取 128px Windows 图标。** 延后:`ExtractAssociatedIcon` 是不需编译片段的 .NET 标准面,32px 在按钮 15-18 CSS px 的尺寸上仅在高分屏略微发软;若实际在意,P/Invoke 变体是脚本内局部升级。同理未追用户的 Linux 图标主题——hicolor 是所有主题继承的 freedesktop 兜底——自定义主题桌面会看到原版图标。
+
+## 后果
+
+- 只要主机在 macOS、Windows 或 Linux 上探测到至少一个已安装的目录应用,Web profile 就会出现头部按钮;其余情况零渲染(探测目录为空 → 组件返回 null)。
+- 社区插件的安装路径仍然有效但已冗余;其原始路由与浏览器选择键独立于 `open-in-app`,因此使用第一方功能的安装应移除社区插件,避免出现重复的头部控件。
+- 解析与图标每主机进程惰性执行一次,dsh 运行期间安装的应用要重启后才出现——接受;卸载方向经 `ENOENT` 单条目刷新自愈。
+- 目录在编译期固定;扩展它意味着同时编辑 `OPEN_IN_APP_CATALOG` 与两份 locale 词典(README 已知限制)。平台覆盖不均——若干 Git GUI 与终端仅有 macOS 条目;Windows 图标受限于 .NET 标准接口的 32px 提取,Linux 跟随 hicolor 而非当前主题,没有 desktop 记录的纯 CLI 条目则保留通用图标。
+- 覆盖:resolver 逻辑(每种 locator 在临时文件系统上、注册表转储与 desktop 条目 fixture、注入的 env/home/PATH 表)、逐平台图标提取、三条路由(真实 Loader + 真实 WebServer 组合,含单趟缓存、`ENOENT` 刷新与 HMR 安全处置)、controller wire 行为和组件呈现都以逐文件 100% 门禁做了单元测试;不新增 snapshot,因为随仓库发布的免密 snapshot fixture 断言会话驱动的输出,而这个纯浏览器侧控件不触及它。Web ARIA golden 禁用 `open-in-app` 与 `ui-open-in-app` 两行,Host-only 的 preset e2e 组合禁用 host 行:按钮反映运行机器实际安装了哪些应用,其出现与否和标签都是主机事实,跨平台 golden 无法钉住。

+ 6 - 0
.agents/notes/implemented/feature/2026-09-07-model-switch-notice.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-07-model-switch-notice.md
+2026-09-07-model-switch-notice.md: cb2f915f13b5fa277977b7a768a3531074126f2a
+2026-09-07-model-switch-notice.zh.md: b6dd8da02732e4b6c6b2102aef6074b8f136d37d

+ 29 - 0
.agents/notes/implemented/feature/2026-09-07-model-switch-notice.md

@@ -0,0 +1,29 @@
+# Agent Note: Model-visible route-change notices
+
+Status: implemented
+
+English | [中文](2026-09-07-model-switch-notice.zh.md)
+
+## Problem
+
+Session history identifies message roles but does not tell a newly selected model which route generated earlier assistant turns. In the motivating session, the user switched from `deepseek-v4-flash` to `deepseek-v4-flash-vision-exp`. The new model saw image placeholders saying that a text-only model had omitted the images and inferred that the limitation described its own image capability.
+
+## Decision
+
+`installModelSelection` compares the provider/model selection captured during prompt assembly with the latest durable request header. When they differ, it appends `[model changed: assistant turns above this point were generated by <previous>; the session continues with <next>]` as an identified user-role message to a downstream `agent/pre-step` decision that would send a model request.
+
+Routes from the same provider use model ids; cross-provider routes use `provider/model`. Initial selection, unchanged routes, reasoning-effort-only changes, rejected steps, and aborted steps add no notice. An empty first decision and a decision that removes offered messages remain no-request results. An empty continuation after a tool call receives the notice because the loop would still request the model from retained history. A selection changed during pre-step processing waits for the next prompt assembly.
+
+The accepted message is logged through the existing `user/message` event before the request header and therefore appears in model input, Chat, and Trajectory. The request header remains the durable record that the new route was used. If a step fails before that header is logged, the next request step repeats the notice because the durable previous route remains unchanged.
+
+## Alternatives considered
+
+**Put the notice in the system prompt.** A transient prompt change would not be reconstructable from the session log and would not mark the exact point where ownership of assistant turns changed.
+
+**Create a separate package or capability.** The behavior only coordinates the prompt snapshot and request route already owned by `installModelSelection`; it has no independent service, provider, or consumer roles.
+
+**Show the change only in the client.** Client-only presentation would leave the newly selected model without the fact needed to interpret earlier assistant turns.
+
+## Consequences
+
+Each emitted route-change notice becomes retained user-role history and consumes context tokens on later requests. A failure before request-header persistence can retain more than one identical notice. Existing session events represent the behavior, so the session format does not change.

+ 29 - 0
.agents/notes/implemented/feature/2026-09-07-model-switch-notice.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: 模型可见的路由切换提示
+
+Status: implemented
+
+[English](2026-09-07-model-switch-notice.md) | 中文
+
+## 问题
+
+会话历史会标明消息角色,但不会告诉新选中的模型,先前的 assistant 消息由哪个路由生成。在引出本改动的会话中,用户从 `deepseek-v4-flash` 切换到 `deepseek-v4-flash-vision-exp`。新模型看到图片占位文本称纯文本模型省略了图片,于是误以为该限制描述的是自己的图片能力。
+
+## 决策
+
+`installModelSelection` 会比较提示词组装时捕获的提供方和模型选择与最新的持久请求 header。两者不同时,它会把 `[model changed: assistant turns above this point were generated by <previous>; the session continues with <next>]` 作为带标识的 user 角色消息,追加到下游原本会发出模型请求的 `agent/pre-step` 决策中。
+
+同一提供方的路由只使用模型 ID;跨提供方的路由使用 `provider/model`。首次选择、未变化的路由、仅推理强度变化、被拒绝的步骤和被取消的步骤都不会增加提示。首次空决策与移除待处理消息后得到的空决策都不会产生请求。工具调用后的空续步仍会基于保留历史请求模型,因此会收到提示。pre-step 处理期间发生的选择变更会等待下一次提示词组装。
+
+被接纳的消息会在请求 header 前通过现有 `user/message` 事件落盘,因此会出现在模型输入、Chat 和 Trajectory 中。请求 header 仍是新路由已被使用的持久记录。如果步骤在该 header 落盘前失败,持久记录中的先前路由保持不变,所以下一个请求步骤会再次发出提示。
+
+## 考虑过的替代方案
+
+**把提示放进系统提示词。** 临时提示词变更无法从会话日志重建,也不能标出 assistant 消息由哪个模型生成的分界点。
+
+**新建单独的包或能力。** 该行为只协调 `installModelSelection` 已经负责的提示词快照与请求路由,没有独立的服务、提供方或消费方角色。
+
+**只在客户端展示切换。** 仅由客户端展示时,新选中的模型仍然缺少解释先前 assistant 消息所需的信息。
+
+## 影响
+
+每条实际发出的路由切换提示都会成为保留的 user 角色历史,并在后续请求中占用上下文 token。请求 header 落盘前发生的失败可能保留多条相同提示。该行为使用现有会话事件,因此会话格式不变。

+ 34 - 24
apps/cli/src/bin.ts

@@ -21,30 +21,40 @@ function readVersion(): string {
   return typeof manifest.version === 'string' ? manifest.version : '0.0.0'
 }
 
-const invocation = parseDshArgs(process.argv.slice(2), readVersion())
+/**
+ * Run the public dsh command-line interface.
+ * @returns a promise that settles when the selected command mode finishes.
+ */
+export async function runCli(): Promise<void> {
+  const invocation = parseDshArgs(process.argv.slice(2), readVersion())
 
-switch (invocation.mode) {
-  case 'profile': {
-    const { runProfile } = await import('./profile-boot.ts')
-    await runProfile({
-      environment: loadLayeredEnv('dsh'),
-      profile: invocation.profile,
-      patchFiles: invocation.patches,
-      args: invocation.args,
-    })
-    break
-  }
-  case 'plugin': {
-    const { runPlugin } = await import('./plugin.ts')
-    process.exit(runPlugin(invocation.profile, invocation.args))
-    break
+  switch (invocation.mode) {
+    case 'profile': {
+      const { runProfile } = await import('./profile-boot.ts')
+      await runProfile({
+        environment: loadLayeredEnv('dsh'),
+        profile: invocation.profile,
+        patchFiles: invocation.patches,
+        args: invocation.args,
+      })
+      break
+    }
+    case 'plugin': {
+      const { runPlugin } = await import('./plugin.ts')
+      process.exit(runPlugin(invocation.profile, invocation.args))
+      break
+    }
+    case 'dump-config': {
+      const { runDumpConfig } = await import('./dump-config.ts')
+      runDumpConfig(invocation.profile, invocation.defaultOnly, invocation.patches)
+      break
+    }
+    default:
+      invocation satisfies never
+      throw new Error(`dsh: unhandled invocation mode ${JSON.stringify(invocation)}`)
   }
-  case 'dump-config': {
-    const { runDumpConfig } = await import('./dump-config.ts')
-    runDumpConfig(invocation.profile, invocation.defaultOnly, invocation.patches)
-    break
-  }
-  default:
-    invocation satisfies never
-    throw new Error(`dsh: unhandled invocation mode ${JSON.stringify(invocation)}`)
+}
+
+if (import.meta.main) {
+  await runCli()
 }

+ 20 - 1
apps/cli/tests/built-bin.e2e.ts

@@ -1,4 +1,4 @@
-import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { createInterface } from 'node:readline'
@@ -617,6 +617,25 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
     }
   })
 
+  it.skipIf(process.platform === 'win32')('runs through an installed-style symlink', async () => {
+    const installation = mkdtempSync(join(tmpdir(), 'dsh-bin-link-'))
+    const installedBin = join(installation, 'dsh')
+    symlinkSync(dshBin, installedBin)
+    try {
+      const result = await execa(process.execPath, [installedBin, '--version'], {
+        input: '',
+        timeout: SPAWN_TIMEOUT_MS,
+        killSignal: 'SIGKILL',
+        reject: false,
+      })
+      expect(result.exitCode).toBe(0)
+      expect(result.stdout).toBe(cliVersion)
+      expect(result.stderr).toBe('')
+    } finally {
+      rmSync(installation, { recursive: true, force: true })
+    }
+  })
+
   it('fails loud on a nonexistent profile with the plugin-command hint', async () => {
     const home = mkdtempSync(join(tmpdir(), 'dsh-missing-profile-'))
     try {

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

@@ -89,6 +89,9 @@ async function bootWeb(
     // Export owns a Connection Fetch route, so this Host-only composition
     // disables it with the transport service above.
     { id: 'session-log-download', disabled: true },
+    // The open-in-app host routes wait for the webserver and connection
+    // rows disabled above (connection's trust fence guards every route).
+    { id: 'open-in-app', disabled: true },
     // The always-on reload chain waits for the browser roster and bound port
     // disabled above.
     { id: 'client-hmr', disabled: true },

+ 1 - 1
apps/cli/tsdown.config.ts

@@ -14,5 +14,5 @@ export default defineConfig({
   target: 'es2024',
   fixedExtension: false,
   dts: false,
-  clean: false,
+  clean: ['lib/*.js'],
 })

+ 1 - 1
apps/web/tests/hmr-live.e2e.ts

@@ -78,7 +78,7 @@ function waitForOutput(child: SubprocessHandle, pattern: RegExp, label: string):
 async function stopTree(child: SubprocessHandle): Promise<void> {
   child.terminate()
   const stopped = await child.waitForExit(AbortSignal.timeout(15_000))
-  if (!stopped) throw new Error(`process tree ${String(child.pid)} did not stop after termination escalation`)
+  if (!stopped) throw new Error('managed process range did not stop after termination escalation')
   await child.done
 }
 

+ 5 - 1
apps/web/tests/preview-boot.e2e.ts

@@ -473,7 +473,11 @@ async function bootEmptyPreview(origin: string, browser: Browser): Promise<void>
     })
     expect(sessionCount).toBe(0)
     expect(pageErrors.map(error => error.message)).toEqual([])
-    expect(failedResponses).toEqual(['/plugins/events'])
+    // Two accepted static-host 404s, sorted (the boot fetches race): the HMR
+    // event stream has no server here, and the open-in-app availability read
+    // has no host routes — the controller publishes an empty list and the
+    // header renders no button, which is that surface's designed degradation.
+    expect([...failedResponses].sort()).toEqual(['/open-in-app/apps', '/plugins/events'])
     expect(consoleErrors.filter(line => !line.includes('Failed to load resource: the server responded with a status of 404')))
       .toEqual([])
   } catch (error) {

+ 7 - 0
apps/web/tests/scaffold.ts

@@ -600,6 +600,13 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
       { id: 'directory-picker-browse', name: '@deepseek-ai/dsh-host-directory-picker-browse' },
       { id: 'ui-directory-picker-browse', name: '@deepseek-ai/dsh-client-ui-directory-picker-browse' },
     ] },
+    // The open-in-app header button reflects the host application probe —
+    // whatever editors and terminals the RUNNING machine has installed — so
+    // its presence and label would vary per host and platform. Pin both rows
+    // off (routes and surface); the packages' own composition and jsdom tests
+    // cover the button.
+    { id: 'open-in-app', disabled: true },
+    { id: 'ui-open-in-app', disabled: true },
     ...options.agentPresets === undefined
       ? []
       // Never the derived harness-home root: a developer's own presets must not

+ 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: 72fa75257fbe933d4a81cc9d1b3b95416ef7109c
-config-catalog.zh.md: b9a9293e458cfdb3a52ec36fb0271fc4f13fc6aa
+config-catalog.md: 32644b4782cc424835f9e303fff5f30ce95cd14f
+config-catalog.zh.md: 24dc4a3cb7acc1b793d25e5ac3cafc1b5e86e14a

+ 36 - 4
docs/config-catalog.md

@@ -309,7 +309,7 @@ export type Config = LocalConfig
 
 Depends on: [`LocalConfig`](#deepseek-aidsh-bash-local)
 
-Source: [`packages/shell/bash-sandbox/src/index.ts:35`](../packages/shell/bash-sandbox/src/index.ts)
+Source: [`packages/shell/bash-sandbox/src/index.ts:36`](../packages/shell/bash-sandbox/src/index.ts)
 
 <a id="deepseek-aidsh-client-connection"></a>
 
@@ -893,6 +893,37 @@ export interface Config {
 
 Source: [`packages/host/frontend-static/src/index.ts:30`](../packages/host/frontend-static/src/index.ts)
 
+<a id="deepseek-aidsh-host-open-in-app"></a>
+
+## `@deepseek-ai/dsh-host-open-in-app`
+
+Requires: `webServer` · `connection` · `subprocess`
+
+```ts config-catalog
+/** Open-in-app host configuration. */
+export interface Config {
+  /**
+   * Per-command deadline in milliseconds for catalog-resolution host
+   * commands (`xcode-select`, the Windows registry reads).
+   */
+  readonly probeTimeoutMs: number
+  /**
+   * Per-command deadline in milliseconds for icon-extraction host commands
+   * (`plutil`/`sips` on macOS, the PowerShell extraction on Windows).
+   */
+  readonly iconTimeoutMs: number
+  /**
+   * Early-failure watch window per launch, in milliseconds: a launcher still
+   * running when the window closes counts as launched and keeps running, so
+   * this bounds how long the open route holds a successful launch, not how
+   * long an application may live.
+   */
+  readonly launchWatchMs: number
+}
+```
+
+Source: [`packages/host/open-in-app/src/index.ts:49`](../packages/host/open-in-app/src/index.ts)
+
 <a id="deepseek-aidsh-host-webserver"></a>
 
 ## `@deepseek-ai/dsh-host-webserver`
@@ -2363,7 +2394,7 @@ export interface Config {
    * `bypassPermissions` explicitly skips permission checks.
    */
   permissionMode?: ClaudeCodePermissionMode
-  /** Grace in milliseconds for Claude Code process-tree termination. */
+  /** Grace in milliseconds between Claude Code managed-range termination tiers. */
   disposeGraceMs?: number
 }
 
@@ -2393,7 +2424,7 @@ export interface Config {
   env?: Record<string, string>
   /** Native non-interactive permission mode fixed for this Provider instance. */
   permissionMode?: CodexPermissionMode
-  /** Grace in milliseconds for app-server process-tree termination. */
+  /** Grace in milliseconds between app-server managed-range termination tiers. */
   disposeGraceMs?: number
 }
 
@@ -2508,7 +2539,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/e2b/subprocess-e2b/src/index.ts:25`](../packages/e2b/subprocess-e2b/src/index.ts)
+Source: [`packages/e2b/subprocess-e2b/src/index.ts:26`](../packages/e2b/subprocess-e2b/src/index.ts)
 
 <a id="deepseek-aidsh-system-prompt"></a>
 
@@ -3384,6 +3415,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
 - `@deepseek-ai/dsh-client-ui-layout` ([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-message-feedback` ([`packages/client/ui-message-feedback/src/index.ts`](../packages/client/ui-message-feedback/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-model-selection` ([`packages/client/ui-model-selection/src/index.ts`](../packages/client/ui-model-selection/src/index.ts))
+- `@deepseek-ai/dsh-client-ui-open-in-app` ([`packages/client/ui-open-in-app/src/index.ts`](../packages/client/ui-open-in-app/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-permission-presets` ([`packages/client/ui-permission-presets/src/index.ts`](../packages/client/ui-permission-presets/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-plan` ([`packages/client/ui-plan/src/index.ts`](../packages/client/ui-plan/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-reference` ([`packages/client/ui-reference/src/index.ts`](../packages/client/ui-reference/src/index.ts))

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

@@ -311,7 +311,7 @@ export type Config = LocalConfig
 
 依赖:[`LocalConfig`](#deepseek-aidsh-bash-local)
 
-来源:[`packages/shell/bash-sandbox/src/index.ts:35`](../packages/shell/bash-sandbox/src/index.ts)
+来源:[`packages/shell/bash-sandbox/src/index.ts:36`](../packages/shell/bash-sandbox/src/index.ts)
 
 <a id="deepseek-aidsh-client-connection"></a>
 
@@ -895,6 +895,37 @@ export interface Config {
 
 来源:[`packages/host/frontend-static/src/index.ts:30`](../packages/host/frontend-static/src/index.ts)
 
+<a id="deepseek-aidsh-host-open-in-app"></a>
+
+## `@deepseek-ai/dsh-host-open-in-app`
+
+需要:`webServer` · `connection` · `subprocess`
+
+```ts config-catalog
+/** Open-in-app host configuration. */
+export interface Config {
+  /**
+   * Per-command deadline in milliseconds for catalog-resolution host
+   * commands (`xcode-select`, the Windows registry reads).
+   */
+  readonly probeTimeoutMs: number
+  /**
+   * Per-command deadline in milliseconds for icon-extraction host commands
+   * (`plutil`/`sips` on macOS, the PowerShell extraction on Windows).
+   */
+  readonly iconTimeoutMs: number
+  /**
+   * Early-failure watch window per launch, in milliseconds: a launcher still
+   * running when the window closes counts as launched and keeps running, so
+   * this bounds how long the open route holds a successful launch, not how
+   * long an application may live.
+   */
+  readonly launchWatchMs: number
+}
+```
+
+来源:[`packages/host/open-in-app/src/index.ts:49`](../packages/host/open-in-app/src/index.ts)
+
 <a id="deepseek-aidsh-host-webserver"></a>
 
 ## `@deepseek-ai/dsh-host-webserver`
@@ -2365,7 +2396,7 @@ export interface Config {
    * `bypassPermissions` explicitly skips permission checks.
    */
   permissionMode?: ClaudeCodePermissionMode
-  /** Grace in milliseconds for Claude Code process-tree termination. */
+  /** Grace in milliseconds between Claude Code managed-range termination tiers. */
   disposeGraceMs?: number
 }
 
@@ -2395,7 +2426,7 @@ export interface Config {
   env?: Record<string, string>
   /** Native non-interactive permission mode fixed for this Provider instance. */
   permissionMode?: CodexPermissionMode
-  /** Grace in milliseconds for app-server process-tree termination. */
+  /** Grace in milliseconds between app-server managed-range termination tiers. */
   disposeGraceMs?: number
 }
 
@@ -2510,7 +2541,7 @@ export interface Config {
 }
 ```
 
-来源:[`packages/e2b/subprocess-e2b/src/index.ts:25`](../packages/e2b/subprocess-e2b/src/index.ts)
+来源:[`packages/e2b/subprocess-e2b/src/index.ts:26`](../packages/e2b/subprocess-e2b/src/index.ts)
 
 <a id="deepseek-aidsh-system-prompt"></a>
 
@@ -3386,6 +3417,7 @@ export interface Config {
 - `@deepseek-ai/dsh-client-ui-layout`([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-message-feedback`([`packages/client/ui-message-feedback/src/index.ts`](../packages/client/ui-message-feedback/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-model-selection`([`packages/client/ui-model-selection/src/index.ts`](../packages/client/ui-model-selection/src/index.ts))
+- `@deepseek-ai/dsh-client-ui-open-in-app`([`packages/client/ui-open-in-app/src/index.ts`](../packages/client/ui-open-in-app/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-permission-presets`([`packages/client/ui-permission-presets/src/index.ts`](../packages/client/ui-permission-presets/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-plan`([`packages/client/ui-plan/src/index.ts`](../packages/client/ui-plan/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-reference`([`packages/client/ui-reference/src/index.ts`](../packages/client/ui-reference/src/index.ts))

+ 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: 7cecb1f362c311cf9b7b617466c1eaa06721a05e
-event-producer-consumer.zh.md: 57af4848a79065cc4ca65d6f56f4d543ec979441
+event-producer-consumer.md: c352fe57795ee3c0c8f8a4adef0b06ff109dec76
+event-producer-consumer.zh.md: 5a091d09ca9ba8ea9ee3274096b1cca181768cfe

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

@@ -16,7 +16,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:242`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
 | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:250`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) |
 | `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
-| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:276`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:276`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
 | `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:289`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
 | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:305`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) |
 | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:262`](../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) |

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

@@ -18,7 +18,7 @@
 | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:242`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
 | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:250`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) |
 | `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
-| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:276`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:276`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
 | `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:289`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
 | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:305`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) |
 | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:262`](../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) |

+ 2 - 2
docs/module-graph.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/module-graph.md
-module-graph.md: 9da7242a5c6fca227175892eaaf65ccf45c61316
-module-graph.zh.md: 58f0e7eda73e8094072390041e683f1cf7e5c140
+module-graph.md: 0951780b0bf53b35f75ef13c50a3875138d756cc
+module-graph.zh.md: 2a073370bd4de913f3a1293fcbebb9b9e25954f4

+ 4 - 0
docs/module-graph.md

@@ -154,6 +154,7 @@ flowchart TD
     pkg_client_ui_layout["client-ui-layout"]
     pkg_client_ui_message_feedback["client-ui-message-feedback"]
     pkg_client_ui_model_selection["client-ui-model-selection"]
+    pkg_client_ui_open_in_app["client-ui-open-in-app"]
     pkg_client_ui_permission_presets["client-ui-permission-presets"]
     pkg_client_ui_plan["client-ui-plan"]
     pkg_client_ui_primitives["client-ui-primitives"]
@@ -237,6 +238,7 @@ flowchart TD
     pkg_host_directory_picker_browse["host-directory-picker-browse"]
     pkg_host_directory_picker_native["host-directory-picker-native"]
     pkg_host_frontend_static["host-frontend-static"]
+    pkg_host_open_in_app["host-open-in-app"]
     pkg_host_plugin_inventory["host-plugin-inventory"]
     pkg_host_webserver["host-webserver"]
   end
@@ -1200,6 +1202,7 @@ flowchart TD
 | [`client-ui-layout`](../packages/client/ui-layout) | `client` | — |
 | [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | — |
 | [`client-ui-model-selection`](../packages/client/ui-model-selection) | `client` | — |
+| [`client-ui-open-in-app`](../packages/client/ui-open-in-app) | `client` | — |
 | [`client-ui-permission-presets`](../packages/client/ui-permission-presets) | `client` | — |
 | [`client-ui-plan`](../packages/client/ui-plan) | `client` | — |
 | [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | — |
@@ -1232,6 +1235,7 @@ flowchart TD
 | [`host-directory-picker`](../packages/host/directory-picker) | `host` | — |
 | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | — |
 | [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | — |
+| [`host-open-in-app`](../packages/host/open-in-app) | `host` | — |
 | [`host-webserver`](../packages/host/webserver) | `host` | — |
 | [`invariants`](../packages/runtime-diagnostics/invariants) | `runtime-diagnostics` | — |
 | [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl) | `sandbox` | — |

+ 4 - 0
docs/module-graph.zh.md

@@ -156,6 +156,7 @@ flowchart TD
     pkg_client_ui_layout["client-ui-layout"]
     pkg_client_ui_message_feedback["client-ui-message-feedback"]
     pkg_client_ui_model_selection["client-ui-model-selection"]
+    pkg_client_ui_open_in_app["client-ui-open-in-app"]
     pkg_client_ui_permission_presets["client-ui-permission-presets"]
     pkg_client_ui_plan["client-ui-plan"]
     pkg_client_ui_primitives["client-ui-primitives"]
@@ -239,6 +240,7 @@ flowchart TD
     pkg_host_directory_picker_browse["host-directory-picker-browse"]
     pkg_host_directory_picker_native["host-directory-picker-native"]
     pkg_host_frontend_static["host-frontend-static"]
+    pkg_host_open_in_app["host-open-in-app"]
     pkg_host_plugin_inventory["host-plugin-inventory"]
     pkg_host_webserver["host-webserver"]
   end
@@ -1202,6 +1204,7 @@ flowchart TD
 | [`client-ui-layout`](../packages/client/ui-layout) | `client` | — |
 | [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | — |
 | [`client-ui-model-selection`](../packages/client/ui-model-selection) | `client` | — |
+| [`client-ui-open-in-app`](../packages/client/ui-open-in-app) | `client` | — |
 | [`client-ui-permission-presets`](../packages/client/ui-permission-presets) | `client` | — |
 | [`client-ui-plan`](../packages/client/ui-plan) | `client` | — |
 | [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | — |
@@ -1234,6 +1237,7 @@ flowchart TD
 | [`host-directory-picker`](../packages/host/directory-picker) | `host` | — |
 | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | — |
 | [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | — |
+| [`host-open-in-app`](../packages/host/open-in-app) | `host` | — |
 | [`host-webserver`](../packages/host/webserver) | `host` | — |
 | [`invariants`](../packages/runtime-diagnostics/invariants) | `runtime-diagnostics` | — |
 | [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl) | `sandbox` | — |

+ 2 - 2
docs/subsystems/shell.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/subsystems/shell.md
-shell.md: 9d5f0431924499716c38d4d155e8993d942788ab
-shell.zh.md: 5874ca44f82d54e9ae4b505a22c7a97a9a0b45d6
+shell.md: 298544569906ddf62ad0703e25beab19204855d7
+shell.zh.md: d4f25e333d57142115af67cdee4f4eb4c7c460ab

+ 8 - 5
docs/subsystems/shell.md

@@ -2,7 +2,7 @@
 
 English | [中文](shell.zh.md)
 
-The bash execution seam is split across a Service Definition ([dsh-shell](../../packages/shell/shell), `ctx.shell`), Service Providers ([dsh-bash-local](../../packages/shell/bash-local) and [dsh-bash-sandbox](../../packages/shell/bash-sandbox)), and Consumer ([dsh-tool-bash](../../packages/shell/tool-bash), the `bash` schema). Generic background-job ids, ownership, and controls live in [jobs.md](jobs.md); this seam returns a task-free process handle. Raw process-group mechanics live behind the [subprocess seam](subprocess.md).
+The bash execution seam is split across a Service Definition ([dsh-shell](../../packages/shell/shell), `ctx.shell`), Service Providers ([dsh-bash-local](../../packages/shell/bash-local) and [dsh-bash-sandbox](../../packages/shell/bash-sandbox)), and Consumer ([dsh-tool-bash](../../packages/shell/tool-bash), the `bash` schema). Generic background-job ids, ownership, and controls live in [jobs.md](jobs.md); this seam returns a task-free process handle. Managed-range mechanics live behind the [subprocess seam](subprocess.md).
 
 Source: [`packages/shell/shell/src/types.ts`](../../packages/shell/shell/src/types.ts)
 
@@ -166,7 +166,7 @@ The `SANDBOX_UNAVAILABLE` error code (owned by the [sandbox seam](sandbox.md)) i
 
 ## Background processes: `ShellProcess`
 
-`start()` returns a handle with no id or owner. `dsh-tool-bash` adapts it into `ctx.jobs.start()` hooks; the generic runtime then owns job identity and lifecycle. `done` resolves when the process closes and never rejects, reads remain valid after settlement, and sandbox facts are stamped before `done` resolves.
+`start()` returns a handle with no id or owner. `dsh-tool-bash` adapts it into `ctx.jobs.start()` hooks; the generic runtime then owns job identity and lifecycle. `done` resolves when the underlying process settles and never rejects; a subprocess provider rejection becomes a `killed` process with a stage-neutral error on stderr. Reads remain valid after settlement, and sandbox facts are stamped before `done` resolves.
 
 ```ts type-equiv
 /**
@@ -182,7 +182,10 @@ interface ShellProcess {
   exitCode: number | null
   /** Terminating signal name, when signal-killed. */
   signal: NodeJS.Signals | null
-  /** Resolves when the underlying process closes (never rejects — a spawn failure settles as `killed` with the error on stderr). */
+  /**
+   * Resolves when the underlying process settles (never rejects — provider
+   * rejection settles as `killed` with a stage-neutral error on stderr).
+   */
   readonly done: Promise<void>
   /** Sandbox facts, stamped once a confined process settles. */
   sandbox?: ShellSandboxInfo
@@ -193,7 +196,7 @@ interface ShellProcess {
    */
   readOutput(): ShellProcessRead
   /**
-   * Kill the process group. Returns false when it had already finished
+   * Terminate the provider-managed range. Returns false when it had already finished
    * (no-op); idempotent.
    */
   kill(): boolean
@@ -218,7 +221,7 @@ interface ShellProcessRead {
 
 ## The service
 
-`ShellExecutor` owns `resolve`, foreground `run`, background-process `start`, and the `sandboxMode` capability fact. `dsh-bash-local` owns command defaulting, timeout/abort classification, the terminal environment, and the background read merge; process groups, bounded collectors, spill files, credential scrubbing, and disposal quiescence are the [subprocess service](subprocess.md)'s. `dsh-tool-bash` owns model-facing rendering and adapts background handles into the [generic job runtime](jobs.md). `dsh-shell` owns the shell tools' shared exit-status contract: the exported `parseExitStatus`/`ParsedExitStatus` inverts the `[exit code: N]` / `[killed by signal: X]` markers `dsh-tool-bash`'s `renderResult` and `dsh-tool-pwsh`'s `renderPwshResult` append, and both tools' `presentResult` use it to split the rendered text into the terminal card's output body and its exit-status pill.
+`ShellExecutor` owns `resolve`, foreground `run`, background-process `start`, and the `sandboxMode` capability fact. `dsh-bash-local` owns command defaulting, timeout/abort classification, the terminal environment, and the background read merge; managed-range termination, bounded collectors, spill files, credential scrubbing, and disposal quiescence are the [subprocess service](subprocess.md)'s. `dsh-tool-bash` owns model-facing rendering and adapts background handles into the [generic job runtime](jobs.md). `dsh-shell` owns the shell tools' shared exit-status contract: the exported `parseExitStatus`/`ParsedExitStatus` inverts the `[exit code: N]` / `[killed by signal: X]` markers `dsh-tool-bash`'s `renderResult` and `dsh-tool-pwsh`'s `renderPwshResult` append, and both tools' `presentResult` use it to split the rendered text into the terminal card's output body and its exit-status pill.
 
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 

+ 8 - 5
docs/subsystems/shell.zh.md

@@ -2,7 +2,7 @@
 
 [English](shell.md) | 中文
 
-bash 执行 seam 分为 Service Definition([dsh-shell](../../packages/shell/shell),`ctx.shell`)、Service Provider([dsh-bash-local](../../packages/shell/bash-local) 与 [dsh-bash-sandbox](../../packages/shell/bash-sandbox))和 Consumer([dsh-tool-bash](../../packages/shell/tool-bash),即 `bash` schema)。通用后台任务的 job id、所有权与控制位于 [jobs.md](jobs.zh.md);本 seam 返回一个不含任务概念的进程句柄。原始进程组机制封装在[子进程 seam](subprocess.zh.md)之后。
+bash 执行 seam 分为 Service Definition([dsh-shell](../../packages/shell/shell),`ctx.shell`)、Service Provider([dsh-bash-local](../../packages/shell/bash-local) 与 [dsh-bash-sandbox](../../packages/shell/bash-sandbox))和 Consumer([dsh-tool-bash](../../packages/shell/tool-bash),即 `bash` schema)。通用后台任务的 job id、所有权与控制位于 [jobs.md](jobs.zh.md);本 seam 返回一个不含任务概念的进程句柄。managed-range 机制封装在[子进程 seam](subprocess.zh.md)之后。
 
 源码:[`packages/shell/shell/src/types.ts`](../../packages/shell/shell/src/types.ts)
 
@@ -166,7 +166,7 @@ interface ShellSandboxInfo {
 
 ## 后台进程:`ShellProcess`
 
-`start()` 返回不含 id 或所有者的句柄。`dsh-tool-bash` 将它适配为 `ctx.jobs.start()` 钩子;随后由通用运行时拥有任务标识与生命周期。`done` 在进程关闭时完成且绝不被拒绝;进程结束后仍可读取,并且沙箱事实会在 `done` 完成前写入。
+`start()` 返回不含 id 或所有者的句柄。`dsh-tool-bash` 将它适配为 `ctx.jobs.start()` 钩子;随后由通用运行时拥有任务标识与生命周期。`done` 会在底层进程结算时完成且绝不 reject;subprocess 提供方的 rejection 会生成状态为 `killed` 的进程,并把不声明阶段的错误写入 stderr。进程结算后仍可读取,并且沙箱事实会在 `done` 完成前写入。
 
 ```ts type-equiv
 /**
@@ -182,7 +182,10 @@ interface ShellProcess {
   exitCode: number | null
   /** Terminating signal name, when signal-killed. */
   signal: NodeJS.Signals | null
-  /** Resolves when the underlying process closes (never rejects — a spawn failure settles as `killed` with the error on stderr). */
+  /**
+   * Resolves when the underlying process settles (never rejects — provider
+   * rejection settles as `killed` with a stage-neutral error on stderr).
+   */
   readonly done: Promise<void>
   /** Sandbox facts, stamped once a confined process settles. */
   sandbox?: ShellSandboxInfo
@@ -193,7 +196,7 @@ interface ShellProcess {
    */
   readOutput(): ShellProcessRead
   /**
-   * Kill the process group. Returns false when it had already finished
+   * Terminate the provider-managed range. Returns false when it had already finished
    * (no-op); idempotent.
    */
   kill(): boolean
@@ -218,7 +221,7 @@ interface ShellProcessRead {
 
 ## 服务
 
-`ShellExecutor` 拥有 `resolve`、前台 `run`、后台进程 `start` 以及 `sandboxMode` 能力事实。`dsh-bash-local` 拥有命令默认值补全、超时/中止分类、终端环境以及后台读取合并;进程组、有界收集器、spill 文件、凭据清除与 dispose(资源释放)后完全停稳归[子进程服务](subprocess.zh.md)所有。`dsh-tool-bash` 拥有面向模型的渲染,并将后台句柄适配到[通用任务运行时](jobs.zh.md)。`dsh-shell` 拥有 shell 工具共享的退出状态约定:导出的 `parseExitStatus`/`ParsedExitStatus` 是 `dsh-tool-bash` 的 `renderResult` 与 `dsh-tool-pwsh` 的 `renderPwshResult` 所追加的 `[exit code: N]` / `[killed by signal: X]` 标记的逆解析,两个工具的 `presentResult` 都用它把渲染文本拆分为 terminal 卡的输出正文与退出状态 pill。
+`ShellExecutor` 拥有 `resolve`、前台 `run`、后台进程 `start` 以及 `sandboxMode` 能力事实。`dsh-bash-local` 拥有命令默认值补全、超时/中止分类、终端环境以及后台读取合并;managed-range 终止、有界收集器、spill 文件、凭据清除与 dispose(资源释放)后完全停稳归[子进程服务](subprocess.zh.md)所有。`dsh-tool-bash` 拥有面向模型的渲染,并将后台句柄适配到[通用任务运行时](jobs.zh.md)。`dsh-shell` 拥有 shell 工具共享的退出状态约定:导出的 `parseExitStatus`/`ParsedExitStatus` 是 `dsh-tool-bash` 的 `renderResult` 与 `dsh-tool-pwsh` 的 `renderPwshResult` 所追加的 `[exit code: N]` / `[killed by signal: X]` 标记的逆解析,两个工具的 `presentResult` 都用它把渲染文本拆分为 terminal 卡的输出正文与退出状态 pill。
 
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 

+ 2 - 2
docs/subsystems/subprocess.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/subsystems/subprocess.md
-subprocess.md: b5d5c61da9d153d9a0d24dab650e310ece154c49
-subprocess.zh.md: 81f0d1aa979c7930d0fa9de9dd56b768b6e3e62d
+subprocess.md: 782623760e70648a108ac51252b610e27bffbe45
+subprocess.zh.md: 7b8e590a843899b5f00a309a913e1dcb152c5c1b

+ 25 - 26
docs/subsystems/subprocess.md

@@ -106,14 +106,15 @@ interface SubprocessSpawnSpec {
   stdio: SubprocessStdio
   /**
    * Positive finite grace period in milliseconds, no greater than
-   * `MAX_TIMER_DELAY_MS`, for the {@link SubprocessHandle.terminate} escalation
-   * and for draining still-open collected pipes after the process exits (an
-   * inherited descriptor held by a surviving descendant cannot hold the
-   * outcome open indefinitely).
+   * `MAX_TIMER_DELAY_MS`, available to the provider's termination procedure
+   * and used for draining still-open collected pipes after the process exits
+   * (an inherited descriptor held by a survivor cannot hold the outcome open
+   * indefinitely). Providers document whether range termination is staged or
+   * immediate.
    */
   graceMs: number
   /**
-   * Abort signal — starts the terminate escalation on the process tree when
+   * Abort signal — starts the terminate escalation on the managed range when
    * it fires. The caller owns deadlines and cause classification; this seam
    * only reacts to the abort.
    */
@@ -129,23 +130,20 @@ interface SubprocessSpawnSpec {
 }
 ```
 
-## Handles: streams, readers, and tree-scoped termination
+## Handles: streams, readers, and managed-range termination
 
-A spawn returns a live handle immediately. Collect-mode readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; piped streams belong to the caller. Termination is tree-scoped on every platform: `terminate()` — the only termination verb — escalates SIGTERM→grace→SIGKILL, and `waitForExit()` observes the whole tree — enough for a consumer to build its own teardown ladder (the ACP backend's stdin-EOF-first `disposeAcpChild` is the template).
+A spawn returns a live handle synchronously while target and managed-range identities remain provider-private. Collect-mode readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; piped streams belong to the caller. `terminate()` starts the provider's documented procedure, and `waitForExit()` observes the same provider-managed range; staged providers may use `graceMs`, while immediate providers do not delay. Consumers can build their own teardown ladders over those two operations (the ACP backend's stdin-EOF-first `disposeAcpChild` is the template).
 
 ```ts type-equiv
 /**
- * A live child process rooted in its own process tree. Collected output
+ * A live subprocess and its provider-managed process range. Collected output
  * remains readable after exit; piped streams belong to the caller.
  *
- * Termination is tree-scoped everywhere: POSIX signals the detached process
- * group (falling back to the direct child when the group is gone), Windows
- * terminates the tree via `taskkill /T`, so helper processes cannot outlive
- * the handle unnoticed.
+ * Termination and {@link SubprocessHandle.waitForExit} use the same managed
+ * range. Each provider documents the range it can observe and its signalling
+ * and observation limits.
  */
 interface SubprocessHandle {
-  /** Process id (tree root); -1 when the spawn itself failed. */
-  readonly pid: number
   /** The child's stdin, present iff spawned with `stdin: 'pipe'`. */
   readonly stdin: Writable | undefined
   /** The child's raw stdout, present iff spawned with `stdout: 'pipe'`. */
@@ -154,20 +152,20 @@ interface SubprocessHandle {
   readonly stderr: Readable | undefined
   /** Offset-based readers for collect-mode streams (also readable after exit). */
   readonly collected: SubprocessCollectedOutputs
-  /** Resolves at process close with exit facts; rejects only for spawn-level failures. */
+  /** Resolves with spawned-command exit facts; rejects for spawn or provider failures. */
   readonly done: Promise<SubprocessOutcome>
   /**
-   * Begin the SIGTERM → `graceMs` → SIGKILL escalation on the process tree
-   * (Windows force-terminates immediately) — the seam's only termination
-   * verb. Idempotent, a no-op once the tree is gone (the pid may be reused),
-   * and also triggered by the spec's abort signal.
+   * Begin the provider's documented termination procedure on the managed range
+   * — the seam's only termination verb. Idempotent, a no-op once that range is
+   * gone, and also triggered by the spec's abort signal.
    */
   terminate(): void
   /**
-   * Wait until the process tree has exited — the tree, not just the direct
-   * child, so a still-running helper is observable before teardown returns.
+   * Wait until the same managed range is empty — not just until the spawned
+   * command reports its outcome, so surviving work remains observable.
    * @param signal - optional bound for the wait.
-   * @returns `true` when the tree exited, `false` when the signal aborted first.
+   * @returns `true` when the managed range is empty, `false` when the signal aborted first.
+   * @throws when the selected provider can no longer observe its managed range.
    */
   waitForExit(signal?: AbortSignal): Promise<boolean>
 }
@@ -246,7 +244,7 @@ The terminal spec fully specifies argv, cwd, environment overrides, dimensions,
 
 ## Service behavior
 
-The abstract [`SubprocessRuntime`](../../packages/subprocess/subprocess/src/index.ts) Service Definition specifies execution-world coordinates, executable lookup, ordinary `spawn`, and `spawnTerminal`. [`LocalSubprocessRuntime`](../../packages/subprocess/subprocess-local/src/index.ts) provides them with detached process trees, per-disposition wiring, credential scrubbing, `node-pty`, platform process inspection, and terminate-and-join disposal. See [`dsh-subprocess`](../../packages/subprocess/subprocess/README.md) for the Service Definition contract and [`dsh-subprocess-local`](../../packages/subprocess/subprocess-local/README.md) for local mechanics.
+The abstract [`SubprocessRuntime`](../../packages/subprocess/subprocess/src/index.ts) Service Definition specifies execution-world coordinates, executable lookup, ordinary `spawn`, and `spawnTerminal`. [`LocalSubprocessRuntime`](../../packages/subprocess/subprocess-local/src/index.ts) provides them with platform-selected managed ranges, per-disposition wiring, credential scrubbing, `node-pty`, platform process inspection, and terminate-and-join disposal. See [`dsh-subprocess`](../../packages/subprocess/subprocess/README.md) for the Service Definition contract and [`dsh-subprocess-local`](../../packages/subprocess/subprocess-local/README.md) for local mechanics.
 
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 
@@ -282,9 +280,9 @@ Abstract subprocess service. Subclass, implement spawn, and load the subclass as
 Implementations must honor these semantics:
 
 - Executable paths belong to one execution world shared with the mounted filesystem provider.
-- spawn returns immediately with a live handle; `done` resolves at process close with exit facts and rejects only for spawn-level failures.
+- spawn returns a live handle synchronously. Target identity remains provider-private; `done` resolves with the spawned command's exit facts and may reject for spawn or provider failures.
 - Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.
-- SubprocessHandle.terminate (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL — the only termination verb — tree-scoped on every platform. SubprocessHandle.waitForExit observes whole-tree liveness, so a consumer-owned teardown ladder can hold each tier on real quiescence.
+- SubprocessHandle.terminate (and the spec's abort signal) starts the provider's documented procedure against its managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its signalling and observability limits.
 - Disposal of the service terminates all still-running managed processes and awaits their exit.
 - spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits.
 
@@ -307,13 +305,14 @@ abstract resolveExecutable( command: string, env?: Readonly<Record<string, strin
  * applies no defaults.
  * @param spec - argv, directory, stdio dispositions, grace, cancellation, and environment.
  * @returns the live process handle (streams/readers, signalling, outcome promise).
+ * @throws synchronously when pre-aborted or when argv, cwd, environment, or grace is invalid before handle creation.
  */
 abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle
 
 /**
  * Allocate a real terminal and start one owned process session. This is the
  * only non-pipe process primitive: implementations own terminal byte I/O,
- * foreground groups, signals, and complete session-tree cleanup.
+ * foreground groups, signals, and whole-session quiescence.
  * @param spec - fully specified argv, cwd, environment, dimensions, grace, and allocation cancellation.
  * @returns the live terminal handle after allocation succeeds.
  */

+ 25 - 26
docs/subsystems/subprocess.zh.md

@@ -106,14 +106,15 @@ interface SubprocessSpawnSpec {
   stdio: SubprocessStdio
   /**
    * Positive finite grace period in milliseconds, no greater than
-   * `MAX_TIMER_DELAY_MS`, for the {@link SubprocessHandle.terminate} escalation
-   * and for draining still-open collected pipes after the process exits (an
-   * inherited descriptor held by a surviving descendant cannot hold the
-   * outcome open indefinitely).
+   * `MAX_TIMER_DELAY_MS`, available to the provider's termination procedure
+   * and used for draining still-open collected pipes after the process exits
+   * (an inherited descriptor held by a survivor cannot hold the outcome open
+   * indefinitely). Providers document whether range termination is staged or
+   * immediate.
    */
   graceMs: number
   /**
-   * Abort signal — starts the terminate escalation on the process tree when
+   * Abort signal — starts the terminate escalation on the managed range when
    * it fires. The caller owns deadlines and cause classification; this seam
    * only reacts to the abort.
    */
@@ -129,23 +130,20 @@ interface SubprocessSpawnSpec {
 }
 ```
 
-## 句柄:流、读取器与以进程树为范围的终止
+## 句柄:流、读取器与 managed-range 终止
 
-spawn 会立即返回一个活动句柄。收集模式的读取器接受全流字节偏移量且从不消费,因此独立读取器不会抢走彼此的增量;管道化的流归调用方所有。终止在每个平台上都以进程树为范围:`terminate()`(唯一的终止动词)执行 SIGTERM→宽限期→SIGKILL 升级,`waitForExit()` 观察整棵进程树。这足以让消费方构建自己的分级清理流程;ACP 后端的 `disposeAcpChild` 会先关闭 stdin,让子进程收到 EOF,是仓库内的参考实现。
+spawn 会同步返回活动句柄,目标与受管范围标识则保留在 provider 内部。收集模式的读取器接受全流字节偏移量且从不消费,因此独立读取器不会抢走彼此的增量;管道化的流归调用方所有。`terminate()` 启动 provider 记录的终止过程,`waitForExit()` 观察同一个 provider-managed range;分阶段 provider 可以使用 `graceMs`,立即终止的 provider 不会等待。消费方可以在这两项操作上构建自己的分级清理流程;ACP 后端先关闭 stdin 的 `disposeAcpChild` 是参考实现。
 
 ```ts type-equiv
 /**
- * A live child process rooted in its own process tree. Collected output
+ * A live subprocess and its provider-managed process range. Collected output
  * remains readable after exit; piped streams belong to the caller.
  *
- * Termination is tree-scoped everywhere: POSIX signals the detached process
- * group (falling back to the direct child when the group is gone), Windows
- * terminates the tree via `taskkill /T`, so helper processes cannot outlive
- * the handle unnoticed.
+ * Termination and {@link SubprocessHandle.waitForExit} use the same managed
+ * range. Each provider documents the range it can observe and its signalling
+ * and observation limits.
  */
 interface SubprocessHandle {
-  /** Process id (tree root); -1 when the spawn itself failed. */
-  readonly pid: number
   /** The child's stdin, present iff spawned with `stdin: 'pipe'`. */
   readonly stdin: Writable | undefined
   /** The child's raw stdout, present iff spawned with `stdout: 'pipe'`. */
@@ -154,20 +152,20 @@ interface SubprocessHandle {
   readonly stderr: Readable | undefined
   /** Offset-based readers for collect-mode streams (also readable after exit). */
   readonly collected: SubprocessCollectedOutputs
-  /** Resolves at process close with exit facts; rejects only for spawn-level failures. */
+  /** Resolves with spawned-command exit facts; rejects for spawn or provider failures. */
   readonly done: Promise<SubprocessOutcome>
   /**
-   * Begin the SIGTERM → `graceMs` → SIGKILL escalation on the process tree
-   * (Windows force-terminates immediately) — the seam's only termination
-   * verb. Idempotent, a no-op once the tree is gone (the pid may be reused),
-   * and also triggered by the spec's abort signal.
+   * Begin the provider's documented termination procedure on the managed range
+   * — the seam's only termination verb. Idempotent, a no-op once that range is
+   * gone, and also triggered by the spec's abort signal.
    */
   terminate(): void
   /**
-   * Wait until the process tree has exited — the tree, not just the direct
-   * child, so a still-running helper is observable before teardown returns.
+   * Wait until the same managed range is empty — not just until the spawned
+   * command reports its outcome, so surviving work remains observable.
    * @param signal - optional bound for the wait.
-   * @returns `true` when the tree exited, `false` when the signal aborted first.
+   * @returns `true` when the managed range is empty, `false` when the signal aborted first.
+   * @throws when the selected provider can no longer observe its managed range.
    */
   waitForExit(signal?: AbortSignal): Promise<boolean>
 }
@@ -246,7 +244,7 @@ interface SubprocessOutcome {
 
 ## 服务行为
 
-抽象的 [`SubprocessRuntime`](../../packages/subprocess/subprocess/src/index.ts) Service Definition 规定执行世界坐标、可执行文件查找、普通 `spawn` 与 `spawnTerminal`。[`LocalSubprocessRuntime`](../../packages/subprocess/subprocess-local/src/index.ts) 以 detached 进程树、按处置方式接线、凭据清除、`node-pty`、平台进程检查,以及先终止再等待退出的资源释放提供这些能力。Service Definition 约定见 [`dsh-subprocess`](../../packages/subprocess/subprocess/README.zh.md),本地机制见 [`dsh-subprocess-local`](../../packages/subprocess/subprocess-local/README.zh.md)。
+抽象的 [`SubprocessRuntime`](../../packages/subprocess/subprocess/src/index.ts) Service Definition 规定执行世界坐标、可执行文件查找、普通 `spawn` 与 `spawnTerminal`。[`LocalSubprocessRuntime`](../../packages/subprocess/subprocess-local/src/index.ts) 以平台选择的 managed range、按处置方式接线、凭据清除、`node-pty`、平台进程检查,以及先终止再等待退出的资源释放提供这些能力。Service Definition 约定见 [`dsh-subprocess`](../../packages/subprocess/subprocess/README.zh.md),本地机制见 [`dsh-subprocess-local`](../../packages/subprocess/subprocess-local/README.zh.md)。
 
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 
@@ -282,9 +280,9 @@ Abstract subprocess service. Subclass, implement spawn, and load the subclass as
 Implementations must honor these semantics:
 
 - Executable paths belong to one execution world shared with the mounted filesystem provider.
-- spawn returns immediately with a live handle; `done` resolves at process close with exit facts and rejects only for spawn-level failures.
+- spawn returns a live handle synchronously. Target identity remains provider-private; `done` resolves with the spawned command's exit facts and may reject for spawn or provider failures.
 - Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.
-- SubprocessHandle.terminate (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL — the only termination verb — tree-scoped on every platform. SubprocessHandle.waitForExit observes whole-tree liveness, so a consumer-owned teardown ladder can hold each tier on real quiescence.
+- SubprocessHandle.terminate (and the spec's abort signal) starts the provider's documented procedure against its managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its signalling and observability limits.
 - Disposal of the service terminates all still-running managed processes and awaits their exit.
 - spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits.
 
@@ -307,13 +305,14 @@ abstract resolveExecutable( command: string, env?: Readonly<Record<string, strin
  * applies no defaults.
  * @param spec - argv, directory, stdio dispositions, grace, cancellation, and environment.
  * @returns the live process handle (streams/readers, signalling, outcome promise).
+ * @throws synchronously when pre-aborted or when argv, cwd, environment, or grace is invalid before handle creation.
  */
 abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle
 
 /**
  * Allocate a real terminal and start one owned process session. This is the
  * only non-pipe process primitive: implementations own terminal byte I/O,
- * foreground groups, signals, and complete session-tree cleanup.
+ * foreground groups, signals, and whole-session quiescence.
  * @param spec - fully specified argv, cwd, environment, dimensions, grace, and allocation cancellation.
  * @returns the live terminal handle after allocation succeeds.
  */

+ 1 - 0
package.json

@@ -180,6 +180,7 @@
     "postinstall": "node scripts/install-lefthook.mjs"
   },
   "devDependencies": {
+    "@deepseek-ai/dsh-agent": "workspace:^",
     "@deepseek-ai/dsh-package-manifest": "workspace:^",
     "@deepseek-ai/dsh-tool-session-query": "workspace:^",
     "@deepseek-ai/dsh-web-fetch-http": "workspace:^",

+ 13 - 0
packages/bundle/web-app/cordis.patch.yml

@@ -59,6 +59,19 @@
     - id: session-log-download
       name: '@deepseek-ai/dsh-session-log-export'
 
+    # Session-header "Open In..." split button over the host application
+    # resolution (macOS/Windows/Linux; a host with no resolved application
+    # renders no button). Two halves: the host routes and the browser surface.
+    - id: open-in-app
+      name: '@deepseek-ai/dsh-host-open-in-app'
+      config:
+        probeTimeoutMs: 10000
+        iconTimeoutMs: 10000
+        launchWatchMs: 1000
+
+    - id: ui-open-in-app
+      name: '@deepseek-ai/dsh-client-ui-open-in-app'
+
     - id: workspace
       name: '@deepseek-ai/dsh-workspace'
 

+ 2 - 0
packages/bundle/web-app/package.json

@@ -59,6 +59,7 @@
     "@deepseek-ai/dsh-client-ui-cordis": "workspace:^",
     "@deepseek-ai/dsh-client-ui-deliverables": "workspace:^",
     "@deepseek-ai/dsh-client-ui-directory-picker-browse": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-open-in-app": "workspace:^",
     "@deepseek-ai/dsh-client-ui-directory-picker-native": "workspace:^",
     "@deepseek-ai/dsh-client-ui-message-feedback": "workspace:^",
     "@deepseek-ai/dsh-client-ui-goal": "workspace:^",
@@ -94,6 +95,7 @@
     "@deepseek-ai/dsh-host-directory-picker-auto": "workspace:^",
     "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^",
     "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^",
+    "@deepseek-ai/dsh-host-open-in-app": "workspace:^",
     "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^",
     "@deepseek-ai/dsh-host-webserver": "workspace:^",
     "@deepseek-ai/dsh-file-reference": "workspace:^",

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/README.md
-README.md: 27fa0abba847d99dd5553a36e25077e4fac3b56c
-README.zh.md: 51a2d40a1ed2ea9a7bfe2ffe9ce96d23a4d27e73
+README.md: 75eda4a46afb21287daa72a7c94fc9cc25aa32d8
+README.zh.md: 808ee9d2a52ae67dac456b58dc1f382ae85ab260

+ 1 - 0
packages/client/README.md

@@ -72,6 +72,7 @@ The kernel packages boot and serve the page; the UI feature packages present it.
 | [`ui-message-feedback/`](ui-message-feedback/README.md) | Contributes per-message feedback controls to the assistant-message action strip | — |
 | [`ui-directory-picker-browse/`](ui-directory-picker-browse/README.md) | In-app directory browsing surface for the workspace directory flow | — |
 | [`ui-directory-picker-native/`](ui-directory-picker-native/README.md) | Native directory-picker surface driving the host's OS chooser | — |
+| [`ui-open-in-app/`](ui-open-in-app/README.md) | Session-header split button opening the workspace directory in an installed application | — |
 
 -----
 

+ 1 - 0
packages/client/README.zh.md

@@ -72,6 +72,7 @@ kind: "package-group"
 | [`ui-message-feedback/`](ui-message-feedback/README.zh.md) | 向助手消息操作条贡献逐消息反馈控件 | — |
 | [`ui-directory-picker-browse/`](ui-directory-picker-browse/README.zh.md) | 面向工作区目录流程的应用内目录浏览界面 | — |
 | [`ui-directory-picker-native/`](ui-directory-picker-native/README.zh.md) | 驱动宿主 OS 选择器的原生目录选择界面 | — |
+| [`ui-open-in-app/`](ui-open-in-app/README.zh.md) | 在已安装应用中打开 workspace 目录的会话头部分体按钮 | — |
 
 -----
 

+ 1 - 1
packages/client/tsdown.client.ts

@@ -58,7 +58,7 @@ function styleInjectionModule(
  * Everything else under @deepseek-ai/* is either a module-table entry
  * (external) or a leak the purity gate rejects.
  */
-export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|deque|output-retention|typert-protocol|util-crypto|util-values|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-token-meter\/client$|@deepseek-ai\/dsh-agent-presets\/display$|@deepseek-ai\/dsh-spill-policy\/notice$)/
+export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|deque|output-retention|typert-protocol|util-crypto|util-values|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-token-meter\/client$|@deepseek-ai\/dsh-host-open-in-app\/shared$|@deepseek-ai\/dsh-agent-presets\/display$|@deepseek-ai\/dsh-spill-policy\/notice$)/
 
 /**
  * Vendored framework libraries: rescoped into @deepseek-ai, so the gate below

+ 6 - 0
packages/client/ui-open-in-app/README.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 packages/client/ui-open-in-app/README.md
+README.md: d242d5a84dffbf0895485f7c79eafd5e332fe446
+README.zh.md: e50e08ab52ce8462af47d75f09a276674d4f4652

+ 83 - 0
packages/client/ui-open-in-app/README.md

@@ -0,0 +1,83 @@
+---
+description: "Web Session-header \"Open In...\" split button: launches the remembered application on the session workspace directory and lists every application the host probed as installed."
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-client-ui-open-in-app
+
+English | [中文](README.zh.md)
+
+## Summary
+
+This package provides the browser surface of the open-in-app feature: a Session-header split button whose main button opens the current session's workspace directory (the summary's `cwd`) in the remembered application, and whose chevron lists every catalog application the host probed as installed. Availability, icons, and launches come from the host routes of [`dsh-host-open-in-app`](../../host/open-in-app/README.md); mount the two packages together. A session without a workspace directory, or a host where nothing nameable is installed, renders no button at all.
+
+## Table of Contents
+
+- [Use this package](#use-this-package)
+- [Understand the implementation](#understand-the-implementation)
+- [Further Exploration](#further-exploration)
+- [Model Experience](#model-experience)
+- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
+- [Dev Note](#dev-note)
+
+-----
+
+<a id="use-this-package"></a>
+## Use this package
+
+Mount this plugin in the Web composition beside [`dsh-host-open-in-app`](../../host/open-in-app/README.md); the pair composes the whole feature in two cordis.yml rows and this row takes no config. The Session header grows an "Open In..." split button whenever the host probed at least one installed catalog application and the session has a known workspace directory.
+
+### What to expect
+
+The main button shows the remembered application's icon — the real application icon wherever the host extracts one (macOS bundle icons, Windows executable icons, Linux theme icons), a generic glyph where it serves none — and a design-system tooltip ("Open locally"); clicking launches immediately. The chevron opens a dense menu of the installed applications with the remembered one marked by a filled row. Availability is read once per page from the host; the last chosen application persists in the browser (`dsh.open-in-app.choice`), and a choice that is no longer installed falls back to the first available entry. A launch that finishes quickly leaves the button untouched — the dimmed busy treatment appears only after 250 ms in flight — and a failed launch shows the error tooltip and a red outline for two seconds. All copy lives in the bilingual `open-in-app` locale namespace; an application id the dictionaries cannot name is not offered.
+
+-----
+
+<a id="understand-the-implementation"></a>
+## Understand the implementation
+
+<details>
+<summary>Implementation internals — click to expand</summary>
+
+The plugin registers the split button on `conversation.session.header.utilities` through the standard slot/inject currency and registers the `open-in-app` dictionaries as one effect. A page-lifetime controller ([`src/client/controller.ts`](src/client/controller.ts)) owns the once-per-page availability read, the persisted choice snapshot store, and the launch POST; the component receives both stores through the inject `hooks` compartment, so every Session header shares one truth. Route paths and wire payload types are inlined from the host package's browser-safe `@deepseek-ai/dsh-host-open-in-app/shared` subpath. In-flight launches are guarded by a ref — repeat clicks and menu picks during a launch are ignored whole (a pick would otherwise persist a choice the gesture never opened) — and the busy/error dress is timer-driven around the `launch` promise. The node half is an empty `apply` that keeps the plugin on the host roster.
+
+</details>
+
+-----
+
+<a id="further-exploration"></a>
+## Further Exploration
+
+- [dsh-host-open-in-app](../../host/open-in-app/README.md) — the host routes serving availability, icons, and launches, and the catalog behind them.
+- [dsh-session-log-export](../../session-query/session-log-export/README.md) — the sibling Session-header action.
+- [Web client architecture](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) — how browser plugin rows load and register slots.
+
+-----
+
+<a id="model-experience"></a>
+## Model Experience
+
+None, as the split button is browser chrome; nothing here reaches a model request.
+
+#### KV Cache effect
+
+None; this package neither assembles nor sends a provider request.
+
+## Known Limitations and Deferred Work
+
+<a id="known-limitations-and-deferred-work"></a>
+
+- **The dictionaries gate the menu.** A host catalog extension without a matching `app.<id>` entry in both dictionaries stays invisible instead of showing a raw id; extending the catalog means extending [`dsh-host-open-in-app`](../../host/open-in-app/README.md) and this package's locales together.
+- **Availability is read once per page.** An application installed while the page is open appears after a reload (and, host-side, after a host restart).
+
+<a id="dev-note"></a>
+### Dev Note
+
+<details>
+<summary>Working context for maintainers — click to expand</summary>
+
+The feature-level decisions, including the split into the host package and this surface, are recorded in the [promotion Agent Note](../../../.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.md).
+
+</details>
+
+**Runtime invariant:** No companion is published. The plugin registers one dictionary effect and one header-slot entry whose disposal the HMR-safety spec proves; availability and choice live in the controller's snapshot stores with no second copy to diverge.

+ 83 - 0
packages/client/ui-open-in-app/README.zh.md

@@ -0,0 +1,83 @@
+---
+description: "Web 会话头部 \"Open In...\" 分体按钮:在记住的应用中打开会话 workspace 目录,并列出主机探测到已安装的全部应用。"
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-client-ui-open-in-app
+
+[English](README.md) | 中文
+
+## 概述
+
+本包提供 open-in-app 功能的浏览器表面:会话头部的一个分体按钮,主按钮在记住的应用中打开当前会话的 workspace 目录(会话摘要的 `cwd`),下拉箭头列出主机探测到已安装的全部目录应用。可用性、图标与启动均来自 [`dsh-host-open-in-app`](../../host/open-in-app/README.zh.md) 的主机路由;两个包应一起挂载。没有 workspace 目录的会话、或没装任何可命名应用的主机,完全不渲染按钮。
+
+## 目录
+
+- [使用本包](#use-this-package)
+- [理解实现](#understand-the-implementation)
+- [进一步探索](#further-exploration)
+- [模型体验](#model-experience)
+- [已知限制与延后工作](#known-limitations-and-deferred-work)
+- [开发备注](#dev-note)
+
+-----
+
+<a id="use-this-package"></a>
+## 使用本包
+
+把本插件与 [`dsh-host-open-in-app`](../../host/open-in-app/README.zh.md) 并排挂进 Web 组合;这对包用两行 cordis.yml 组成完整功能,本行不接受任何 config。只要主机探测到至少一个已安装的目录应用且会话有已知的 workspace 目录,会话头部就会出现 "Open In..." 分体按钮。
+
+### 预期行为
+
+主按钮显示记住的应用图标——凡主机能提取的都是应用真实图标(macOS bundle 图标、Windows 可执行文件图标、Linux 主题图标),提取不到时是通用占位图形——并带设计系统 tooltip(「在本地打开」);点击立即启动。下拉箭头打开已安装应用的紧凑菜单,记住的条目以整行填充标记。可用性每页读取一次;上次选择的应用持久化在浏览器中(`dsh.open-in-app.choice`),不再安装的选择回退到第一个可用条目。快速完成的启动不改变按钮外观——变暗的等待态只在飞行超过 250 毫秒后出现——失败的启动显示错误 tooltip 与红色描边两秒。所有文案在双语 `open-in-app` locale 命名空间中;词典无法命名的应用 id 不会被提供。
+
+-----
+
+<a id="understand-the-implementation"></a>
+## 理解实现
+
+<details>
+<summary>实现内幕——点击展开</summary>
+
+插件经标准 slot/inject 通货把分体按钮注册到 `conversation.session.header.utilities`,并以一个 effect 注册 `open-in-app` 词典。一个页面生命周期的 controller([`src/client/controller.ts`](src/client/controller.ts))拥有每页一次的可用性读取、持久化选择的 snapshot store 与启动 POST;组件经 inject 的 `hooks` 隔间接收两个 store,因此所有会话头部共享同一份事实。路由路径与 wire 载荷类型从主机包的浏览器安全子路径 `@deepseek-ai/dsh-host-open-in-app/shared` 内联。飞行中的启动由 ref 守卫——启动期间的重复点击与菜单选择被整体忽略(否则会持久化一个该手势从未打开的选择)——busy/error 视觉由围绕 `launch` promise 的定时器驱动。节点半边是一个空 `apply`,让插件出现在主机侧的插件名册上。
+
+</details>
+
+-----
+
+<a id="further-exploration"></a>
+## 进一步探索
+
+- [dsh-host-open-in-app](../../host/open-in-app/README.zh.md)——提供可用性、图标与启动的主机路由,及其背后的目录。
+- [dsh-session-log-export](../../session-query/session-log-export/README.zh.md)——会话头部的姊妹动作。
+- [Web client 架构](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md)——浏览器插件行如何加载并注册 slot。
+
+-----
+
+<a id="model-experience"></a>
+## 模型体验
+
+无。分体按钮是浏览器 chrome;这里没有任何东西进入模型请求。
+
+#### KV 缓存影响
+
+无;本包从不组装或发送 provider 请求。
+
+## 已知限制与延后工作
+
+<a id="known-limitations-and-deferred-work"></a>
+
+- **词典把守菜单。** 主机目录的新条目若在两份词典中没有对应的 `app.<id>` 条目,将保持不可见而不是显示裸 id;扩展目录意味着同时扩展 [`dsh-host-open-in-app`](../../host/open-in-app/README.zh.md) 与本包的 locale。
+- **可用性每页只读一次。** 页面打开期间安装的应用要重新加载页面后才出现(主机侧还需主机重启)。
+
+<a id="dev-note"></a>
+### 开发备注
+
+<details>
+<summary>维护者工作语境——点击展开</summary>
+
+功能层面的各项决定,包括拆分为主机包与本表面包,记录在[转正 Agent Note](../../../.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.zh.md)。
+
+</details>
+
+**运行时不变量:** 不发布 companion。插件注册一个词典 effect 与一个头部 slot 条目,HMR 安全测试已证明其可处置;可用性与选择存于 controller 的 snapshot store,没有可能分叉的第二份副本。

+ 68 - 0
packages/client/ui-open-in-app/package.json

@@ -0,0 +1,68 @@
+{
+  "name": "@deepseek-ai/dsh-client-ui-open-in-app",
+  "description": "Web Session-header \"Open In...\" split button opening the session workspace directory in a locally installed application",
+  "version": "0.1.3-alpha.1",
+  "publishConfig": {
+    "access": "public"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+    "directory": "packages/client/ui-open-in-app"
+  },
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./client": {
+      "types": "./lib/types/client/index.d.ts",
+      "default": "./lib/client.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/client.js",
+    "lib/types/**/*.d.ts"
+  ],
+  "scripts": {
+    "bundle": "tsdown",
+    "watch": "tsdown --watch"
+  },
+  "license": "MIT",
+  "dsh": {
+    "client": {
+      "inject": [
+        "@deepseek-ai/dsh-client-locale",
+        "@deepseek-ai/dsh-client-ui-conversation",
+        "@deepseek-ai/dsh-client-ui-renderer",
+        "@deepseek-ai/dsh-client-ui-session"
+      ],
+      "platform": "web"
+    }
+  },
+  "peerDependencies": {
+    "@deepseek-ai/cordis": "workspace:^"
+  },
+  "devDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-api-session-controller": "workspace:^",
+    "@deepseek-ai/dsh-client-locale": "workspace:^",
+    "@deepseek-ai/dsh-client-store": "workspace:^",
+    "@deepseek-ai/dsh-client-test-runtime": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-session": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
+    "@deepseek-ai/dsh-host-open-in-app": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@types/react": "~18.3.1",
+    "react": "^18.2.0"
+  }
+}

+ 65 - 0
packages/client/ui-open-in-app/src/client/OpenInAppAction.module.css

@@ -0,0 +1,65 @@
+/* The split button sits beside the session-log capsule at the same compact
+   scale (26px tall, pill radius, hairline l4 border, 11px primary-color label). */
+
+.split {
+  display: inline-flex;
+  align-items: stretch;
+  box-sizing: border-box;
+  height: 26px;
+  border: 0.5px solid var(--dsw-alias-border-l4);
+  border-radius: 13px;
+  overflow: hidden;
+  font-family: var(--dsw-font-family);
+}
+
+.main,
+.chevron {
+  display: inline-flex;
+  align-items: center;
+  gap: 5px;
+  border: 0;
+  background: none;
+  color: var(--dsw-alias-label-primary);
+  font-size: 11px;
+  font-weight: 400;
+  line-height: 16px;
+  cursor: pointer;
+  white-space: nowrap;
+}
+
+.main {
+  padding: 5px 6px 5px 7px;
+}
+
+.main:hover:not(:disabled),
+.main:focus-visible,
+.chevron:hover,
+.chevron:focus-visible {
+  background: var(--dsw-alias-interactive-bg-hover);
+}
+
+.main:disabled {
+  color: var(--dsw-alias-label-dimmed);
+  cursor: wait;
+}
+
+.main[data-state='error'] {
+  color: var(--dsw-alias-state-error-primary);
+  box-shadow: inset 0 0 0 1px var(--dsw-alias-state-error-primary);
+}
+
+.chevron {
+  padding: 5px 6px 5px 4px;
+  border-left: 0.5px solid var(--dsw-alias-border-l4);
+  color: var(--dsw-alias-label-secondary);
+}
+
+.icon {
+  flex: none;
+}
+
+img.icon {
+  display: block;
+  object-fit: contain;
+  user-select: none;
+}

+ 228 - 0
packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx

@@ -0,0 +1,228 @@
+import { useEffect, useRef, useState } from 'react'
+import { IconChevronDownOutline14, Menu, Tooltip, type MenuItem } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
+import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
+import { NS, type OpenInAppKey } from './locales.ts'
+import css from './OpenInAppAction.module.css'
+
+/** Browser operations and state injected into the Session Header contribution. */
+export interface OpenInAppActionInjected {
+  hooks: {
+    openInAppApps: ObservableSnapshot<readonly string[] | null>
+    openInAppChoice: ObservableSnapshot<string>
+  }
+  launch: (appId: string, path: string) => Promise<void>
+  choose: (appId: string) => void
+  iconUrl: (appId: string) => string
+}
+
+/** Full props for the Session-header open-in-app split button. */
+export type OpenInAppActionProps =
+  PropsRuntime<'conversation.session.header.utilities'>
+  & PropsLocale<typeof NS>
+  & InjectFace<OpenInAppActionInjected>
+
+/**
+ * Label keys per catalog id: the browser renders only ids it can name, so a
+ * host catalog extension without a matching dictionary entry stays invisible
+ * instead of showing a raw id.
+ */
+const APP_LABEL_KEY: Record<string, OpenInAppKey | undefined> = {
+  finder: 'app.finder',
+  explorer: 'app.explorer',
+  filemanager: 'app.filemanager',
+  cursor: 'app.cursor',
+  vscode: 'app.vscode',
+  vscodeinsiders: 'app.vscodeinsiders',
+  windsurf: 'app.windsurf',
+  zed: 'app.zed',
+  sublimetext: 'app.sublimetext',
+  xcode: 'app.xcode',
+  androidstudio: 'app.androidstudio',
+  intellij: 'app.intellij',
+  pycharm: 'app.pycharm',
+  webstorm: 'app.webstorm',
+  phpstorm: 'app.phpstorm',
+  goland: 'app.goland',
+  rider: 'app.rider',
+  rustrover: 'app.rustrover',
+  fork: 'app.fork',
+  sourcetree: 'app.sourcetree',
+  github: 'app.github',
+  tower: 'app.tower',
+  gitkraken: 'app.gitkraken',
+  smartgit: 'app.smartgit',
+  sublimemerge: 'app.sublimemerge',
+  ghostty: 'app.ghostty',
+  warp: 'app.warp',
+  iterm: 'app.iterm',
+  kitty: 'app.kitty',
+  terminal: 'app.terminal',
+  windowsterminal: 'app.windowsterminal',
+  gitbash: 'app.gitbash',
+  gnometerminal: 'app.gnometerminal',
+  konsole: 'app.konsole',
+}
+
+/** App ids whose icon image already failed this page; a 404 icon is fetched once, not per menu open. */
+const failedIcons = new Set<string>()
+
+/**
+ * One application's real bundle icon (host-served PNG) with an inline generic
+ * app-square fallback while the host has none.
+ * @param props - catalog id, host icon URL, and rendered size.
+ * @returns the icon image or its fallback glyph.
+ */
+function AppIcon({ id, url, size }: { id: string; url: string; size: number }): React.JSX.Element {
+  const [failed, setFailed] = useState(failedIcons.has(id))
+  if (failed) {
+    return (
+      <svg
+        width={size}
+        height={size}
+        viewBox="0 0 24 24"
+        fill="none"
+        stroke="currentColor"
+        strokeWidth={1.8}
+        className={css.icon}
+        aria-hidden
+      >
+        <rect x={3} y={3} width={18} height={18} rx={5} />
+      </svg>
+    )
+  }
+  return (
+    <img
+      src={url}
+      width={size}
+      height={size}
+      className={css.icon}
+      alt=""
+      aria-hidden
+      draggable={false}
+      onError={() => {
+        failedIcons.add(id)
+        setFailed(true)
+      }}
+    />
+  )
+}
+
+/**
+ * Quick launches settle well under this delay, so their busy dress never
+ * paints — the visible dim-and-wait treatment is reserved for launches that
+ * are actually taking a while, instead of flashing on every click.
+ */
+const BUSY_DRESS_DELAY_MS = 250
+
+/**
+ * Session-header split button: the main button opens the session's workspace
+ * directory in the remembered application, the chevron opens the menu of
+ * every application the host probed as installed. It renders nothing until
+ * the host reported at least one nameable application and the session has a
+ * known workspace directory, so a host without the capability never grows
+ * the control.
+ * @param props - session runtime, injected controller face, and localized copy.
+ * @returns the split button and its menu, or null when there is nothing to offer.
+ */
+export function OpenInAppAction(props: OpenInAppActionProps): React.JSX.Element | null {
+  const { sessionId, useSessions, useOpenInAppApps, useOpenInAppChoice, t } = props
+  const cwd = useSessions(state => state.byId[sessionId]?.cwd)
+  const available = useOpenInAppApps(apps => apps)
+  const choice = useOpenInAppChoice(id => id)
+  const [open, setOpen] = useState(false)
+  const [phase, setPhase] = useState<'idle' | 'busy' | 'error'>('idle')
+  const inFlight = useRef(false)
+  const busyTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
+  const errorTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
+
+  useEffect(() => () => {
+    clearTimeout(busyTimer.current)
+    clearTimeout(errorTimer.current)
+  }, [])
+
+  const apps = (available ?? [])
+    .map(id => ({ id, labelKey: APP_LABEL_KEY[id] }))
+    .filter((entry): entry is { id: string; labelKey: OpenInAppKey } => entry.labelKey !== undefined)
+  const currentEntry = apps.find(entry => entry.id === choice) ?? apps[0]
+  if (currentEntry === undefined || cwd === undefined || cwd === '') return null
+
+  const current = currentEntry.id
+  const currentLabel = t(currentEntry.labelKey)
+  const title = phase === 'error' ? t('open.error') : t('open.title', { app: currentLabel })
+
+  const launch = (appId: string): void => {
+    if (inFlight.current) return
+    inFlight.current = true
+    // A pending error decay must not flip the button back to idle mid-launch.
+    clearTimeout(errorTimer.current)
+    clearTimeout(busyTimer.current)
+    busyTimer.current = setTimeout(() => { setPhase('busy') }, BUSY_DRESS_DELAY_MS)
+    props.launch(appId, cwd).then(() => {
+      inFlight.current = false
+      clearTimeout(busyTimer.current)
+      setPhase('idle')
+    }, () => {
+      inFlight.current = false
+      clearTimeout(busyTimer.current)
+      setPhase('error')
+      clearTimeout(errorTimer.current)
+      errorTimer.current = setTimeout(() => { setPhase('idle') }, 2_000)
+    })
+  }
+
+  const items: MenuItem[] = apps.map(entry => ({
+    id: entry.id,
+    label: t(entry.labelKey),
+    icon: <AppIcon id={entry.id} url={props.iconUrl(entry.id)} size={18} />,
+  }))
+
+  return (
+    <Menu
+      open={open}
+      align="end"
+      dense
+      selection="fill"
+      onClose={() => { setOpen(false) }}
+      items={items}
+      selectedId={current}
+      onSelect={(id) => {
+        setOpen(false)
+        // A pick while a launch is in flight is ignored whole: persisting the
+        // choice without launching would leave the button naming an app the
+        // gesture never opened.
+        if (inFlight.current) return
+        props.choose(id)
+        launch(id)
+      }}
+      anchor={(
+        <div className={css.split}>
+          <Tooltip label={phase === 'error' ? t('open.error') : t('open.tooltip')} side="bottom">
+            <button
+              type="button"
+              className={css.main}
+              data-state={phase}
+              disabled={phase === 'busy'}
+              aria-label={title}
+              onClick={() => { launch(current) }}
+            >
+              <AppIcon id={current} url={props.iconUrl(current)} size={15} />
+            </button>
+          </Tooltip>
+          <button
+            type="button"
+            className={css.chevron}
+            aria-expanded={open}
+            aria-haspopup="menu"
+            title={t('menu.toggle')}
+            aria-label={t('menu.toggle')}
+            onClick={() => { setOpen(value => !value) }}
+          >
+            <IconChevronDownOutline14 size={11} />
+          </button>
+        </div>
+      )}
+    />
+  )
+}

+ 87 - 0
packages/client/ui-open-in-app/src/client/controller.ts

@@ -0,0 +1,87 @@
+/** Browser availability/choice state and the launch carrier for the split button. */
+
+import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
+import {
+  OPEN_IN_APP_APPS_ROUTE, OPEN_IN_APP_OPEN_ROUTE,
+  type OpenInAppAppsPayload, type OpenInAppOpenPayload,
+} from '@deepseek-ai/dsh-host-open-in-app/shared'
+
+type Fetch = (input: string | URL, init?: RequestInit) => Promise<Response>
+
+/** Resolve the browser's Host base with the connection carrier's null-origin fallback. */
+function hostBase(): string {
+  const origin = (globalThis as { location?: { origin?: string } }).location?.origin
+  return origin !== undefined && origin !== 'null' ? origin : 'http://dsh.internal'
+}
+
+/**
+ * Owns the once-per-page availability read, the persisted last choice, and
+ * the launch POST. Availability and choice publish through uSES-safe sources
+ * so every Session header shares one truth.
+ */
+export class OpenInAppController {
+  /** Installed app ids in host menu order; null until the host answered. */
+  readonly apps: SnapshotStore<readonly string[] | null> = createSnapshotStore<readonly string[] | null>(null)
+  /** Last chosen app id, or empty before the first choice, shared across sessions and browser restarts. */
+  readonly choice: SnapshotStore<string> = createSnapshotStore<string>('', {
+    persist: { name: 'dsh.open-in-app.choice' },
+  })
+
+  private loading: Promise<void> | undefined
+
+  /**
+   * @param fetcher - HTTP carrier for the apps read and the launch POST.
+   */
+  constructor(private readonly fetcher: Fetch = (input, init) => fetch(input, init)) {}
+
+  /**
+   * Read availability once per controller life; concurrent calls share the read.
+   * A failed read publishes an empty list, which renders no button at all.
+   * @returns after availability is published.
+   */
+  load(): Promise<void> {
+    this.loading ??= this.run()
+    return this.loading
+  }
+
+  /**
+   * Remember one picked app id.
+   * @param appId - catalog id from the availability list.
+   */
+  choose(appId: string): void {
+    this.choice.set(appId)
+  }
+
+  /**
+   * Launch one installed app on a workspace directory.
+   * @param appId - catalog id from the availability list.
+   * @param path - the session's absolute workspace directory.
+   * @returns after the host acknowledged the launch; rejects on any failure.
+   */
+  async launch(appId: string, path: string): Promise<void> {
+    const body: OpenInAppOpenPayload = { app: appId, path }
+    const response = await this.fetcher(new URL(OPEN_IN_APP_OPEN_ROUTE, hostBase()), {
+      method: 'POST',
+      headers: { 'content-type': 'application/json' },
+      body: JSON.stringify(body),
+    })
+    if (!response.ok) throw new Error(`open failed: HTTP ${String(response.status)}`)
+  }
+
+  private async run(): Promise<void> {
+    let apps: readonly string[] = []
+    try {
+      const response = await this.fetcher(new URL(OPEN_IN_APP_APPS_ROUTE, hostBase()), {
+        headers: { accept: 'application/json' },
+      })
+      if (response.ok) {
+        const payload = await response.json() as OpenInAppAppsPayload
+        if (Array.isArray(payload.apps)) apps = payload.apps.filter(id => typeof id === 'string')
+      }
+    } catch {
+      // Swallows network failures: an unreachable host reads as no apps, and
+      // the header simply shows no button rather than a broken one.
+    }
+    this.apps.set(apps)
+  }
+}

+ 53 - 0
packages/client/ui-open-in-app/src/client/index.ts

@@ -0,0 +1,53 @@
+/**
+ * Browser half of open-in-app: one Session-header split button opening the
+ * session's workspace directory (the summary's `cwd`) in the remembered
+ * installed application. Availability arrives once per page from the host
+ * apps route; the last choice persists in the browser through the controller's
+ * persisted snapshot store.
+ */
+
+import type { Context as ClientContext } from '@deepseek-ai/cordis'
+import type {} from '@deepseek-ai/dsh-client-locale/client'
+import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
+import type {} from '@deepseek-ai/dsh-client-ui-session/client'
+import { OPEN_IN_APP_ICON_PREFIX } from '@deepseek-ai/dsh-host-open-in-app/shared'
+import { OpenInAppController } from './controller.ts'
+import { OpenInAppAction, type OpenInAppActionInjected } from './OpenInAppAction.tsx'
+import { en, NS, zh, type OpenInAppKey } from './locales.ts'
+
+declare module '@deepseek-ai/dsh-client-ui-slots' {
+  interface LocaleNamespaceMap {
+    /** Session-header "open workspace in application" copy. */
+    'open-in-app': OpenInAppKey
+  }
+}
+
+export type { OpenInAppActionInjected, OpenInAppActionProps } from './OpenInAppAction.tsx'
+
+/** Required services for locale registration and the header-slot contribution. */
+export const inject = ['sessions', 'slots', 'locale']
+
+/**
+ * Client plugin body: register the dictionaries and the header split button.
+ * @param ctx - client root context.
+ */
+export function apply(ctx: ClientContext): void {
+  const controller = new OpenInAppController()
+  void controller.load()
+  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'open-in-app: dictionaries')
+  ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register({
+    name: 'conversation.session.header.utilities',
+    id: 'open-in-app',
+    order: -10,
+    locale: NS,
+    inject: (): OpenInAppActionInjected => ({
+      hooks: {
+        openInAppApps: controller.apps,
+        openInAppChoice: controller.choice,
+      },
+      launch: (appId, path) => controller.launch(appId, path),
+      choose: (appId) => { controller.choose(appId) },
+      iconUrl: appId => `${OPEN_IN_APP_ICON_PREFIX}/${appId}`,
+    }),
+  }, OpenInAppAction))
+}

+ 69 - 0
packages/client/ui-open-in-app/src/client/locales.ts

@@ -0,0 +1,69 @@
+/** `open-in-app` namespace dictionaries. */
+
+/** Dictionary namespace owned by this plugin. */
+export const NS = 'open-in-app'
+
+/** Application labels shared verbatim by both dictionaries (product names). */
+const PRODUCT_NAMES = {
+  'app.cursor': 'Cursor',
+  'app.vscode': 'VS Code',
+  'app.vscodeinsiders': 'VS Code Insiders',
+  'app.windsurf': 'Windsurf',
+  'app.zed': 'Zed',
+  'app.sublimetext': 'Sublime Text',
+  'app.xcode': 'Xcode',
+  'app.androidstudio': 'Android Studio',
+  'app.intellij': 'IntelliJ IDEA',
+  'app.pycharm': 'PyCharm',
+  'app.webstorm': 'WebStorm',
+  'app.phpstorm': 'PhpStorm',
+  'app.goland': 'GoLand',
+  'app.rider': 'Rider',
+  'app.rustrover': 'RustRover',
+  'app.fork': 'Fork',
+  'app.sourcetree': 'Sourcetree',
+  'app.github': 'GitHub Desktop',
+  'app.tower': 'Tower',
+  'app.gitkraken': 'GitKraken',
+  'app.smartgit': 'SmartGit',
+  'app.sublimemerge': 'Sublime Merge',
+  'app.ghostty': 'Ghostty',
+  'app.warp': 'Warp',
+  'app.iterm': 'iTerm2',
+  'app.kitty': 'kitty',
+  'app.windowsterminal': 'Windows Terminal',
+  'app.gitbash': 'Git Bash',
+  'app.gnometerminal': 'GNOME Terminal',
+  'app.konsole': 'Konsole',
+} as const
+
+/** Simplified Chinese dictionary (the key-set source of truth). */
+export const zh = {
+  'open.title': '在 {app} 中打开工作目录',
+  'open.tooltip': '在本地打开',
+  'open.error': '打开失败',
+  'menu.toggle': '选择打开方式',
+  'menu.aria': '打开方式',
+  ...PRODUCT_NAMES,
+  'app.finder': '访达',
+  'app.explorer': '文件资源管理器',
+  'app.filemanager': '文件管理器',
+  'app.terminal': '终端',
+} as const
+
+/** English dictionary, key-identical to the Chinese source of truth. */
+export const en: Record<OpenInAppKey, string> = {
+  'open.title': 'Open workspace in {app}',
+  'open.tooltip': 'Open locally',
+  'open.error': 'Failed to open',
+  'menu.toggle': 'Choose an app to open in',
+  'menu.aria': 'Open in',
+  ...PRODUCT_NAMES,
+  'app.finder': 'Finder',
+  'app.explorer': 'File Explorer',
+  'app.filemanager': 'Files',
+  'app.terminal': 'Terminal',
+}
+
+/** Key domain of the `open-in-app` namespace (zh is the source of truth). */
+export type OpenInAppKey = keyof typeof zh

+ 6 - 0
packages/client/ui-open-in-app/src/css-modules.d.ts

@@ -0,0 +1,6 @@
+declare module '*.module.css' {
+  const classes: Record<string, string>
+  export default classes
+}
+
+declare module '*.css'

+ 10 - 0
packages/client/ui-open-in-app/src/index.ts

@@ -0,0 +1,10 @@
+/**
+ * Open-in-app browsing surface, node half. Pure UI plugin: the empty apply
+ * exists so the plugin appears in the host cordis.yml / Loader; the browser
+ * half ships via exports["./client"], discovered through the package.json
+ * dsh.client declaration. The routes it drives live in
+ * `@deepseek-ai/dsh-host-open-in-app`.
+ */
+
+/** Host plugin body — no host-side behavior for this surface plugin. */
+export function apply(): void {}

+ 125 - 0
packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts

@@ -0,0 +1,125 @@
+/**
+ * Browser-half lifecycle over the real SlotRegistry: the dictionary and
+ * header-slot registrations with fiber teardown proving removal (HMR safety)
+ * and the injected controller face.
+ */
+
+import { Context } from '@deepseek-ai/cordis'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
+import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
+import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
+import { apply, inject, type OpenInAppActionInjected } from '../src/client/index.ts'
+import { apply as nodeApply } from '../src/index.ts'
+import { OpenInAppAction } from '../src/client/OpenInAppAction.tsx'
+import { en, NS, zh } from '../src/client/locales.ts'
+
+afterEach(() => {
+  vi.unstubAllGlobals()
+})
+
+/** Boot the browser half over a real slot tree that declares the header list. */
+async function bench(): Promise<{ ctx: Context; fiber: ReturnType<Context['plugin']> }> {
+  const ctx = new Context()
+  await ctx.plugin(SlotRegistry).await()
+  ctx.slots.register({
+    name: 'root',
+    children: {
+      'conversation.session.header.utilities': { kind: 'list', scope: 'session' },
+    },
+  } as never, () => null)
+  ctx.provide('sessions', {})
+  ctx.provide('locale', new LocaleRuntime(ctx))
+  const fiber = ctx.plugin({ inject: [...inject], apply })
+  await fiber.await()
+  return { ctx, fiber }
+}
+
+function headerEntryIds(ctx: Context): (string | undefined)[] {
+  return ctx.slots.entries('conversation.session.header.utilities').map(entry => entry.options.id)
+}
+
+describe('open-in-app browser half', () => {
+  it('declares the services it binds', () => {
+    expect(inject).toEqual(['sessions', 'slots', 'locale'])
+  })
+
+  it('registers the header split button, and fiber teardown removes it (HMR safety)', async () => {
+    vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ apps: [] }), { status: 200 })))
+    const { ctx, fiber } = await bench()
+    const entry = ctx.slots.entries('conversation.session.header.utilities')[0]
+    expect(entry?.component).toBe(OpenInAppAction)
+    expect(entry?.options).toMatchObject({ id: 'open-in-app' })
+    await fiber.dispose()
+    expect(headerEntryIds(ctx)).not.toContain('open-in-app')
+  })
+
+  it('injects the controller face: availability sources, launch carrier, choice, and icon URLs', async () => {
+    const fetcher = vi.fn(async (input: string | URL, init?: RequestInit) => {
+      void init
+      const url = String(input)
+      if (url.includes('/open-in-app/apps')) {
+        return new Response(JSON.stringify({ apps: ['finder', 'cursor', 7] }), { status: 200 })
+      }
+      return new Response(JSON.stringify({ ok: true }), { status: 200 })
+    })
+    vi.stubGlobal('fetch', fetcher)
+    const { ctx, fiber } = await bench()
+    const entry = ctx.slots.entries('conversation.session.header.utilities')[0]
+    const injected = (entry?.inject as unknown as () => OpenInAppActionInjected)()
+
+    await vi.waitFor(() => {
+      expect(injected.hooks.openInAppApps.getSnapshot()).toEqual(['finder', 'cursor'])
+    })
+    expect(injected.iconUrl('cursor')).toBe('/open-in-app/icon/cursor')
+
+    injected.choose('cursor')
+    expect(injected.hooks.openInAppChoice.getSnapshot()).toBe('cursor')
+
+    await injected.launch('cursor', '/w/dir')
+    const openCall = fetcher.mock.calls.find(call => String(call[0]).includes('/open-in-app/open'))
+    expect(openCall?.[1]).toMatchObject({
+      method: 'POST',
+      headers: { 'content-type': 'application/json' },
+      body: JSON.stringify({ app: 'cursor', path: '/w/dir' }),
+    })
+    await fiber.dispose()
+  })
+
+  it('publishes an empty availability list when the host read fails, and launches reject on HTTP errors', async () => {
+    vi.stubGlobal('fetch', vi.fn(async (input: string | URL) => {
+      if (String(input).includes('/open-in-app/apps')) throw new Error('down')
+      return new Response('', { status: 502 })
+    }))
+    const { ctx, fiber } = await bench()
+    const entry = ctx.slots.entries('conversation.session.header.utilities')[0]
+    const injected = (entry?.inject as unknown as () => OpenInAppActionInjected)()
+    await vi.waitFor(() => {
+      expect(injected.hooks.openInAppApps.getSnapshot()).toEqual([])
+    })
+    await expect(injected.launch('finder', '/w/dir')).rejects.toThrow('open failed: HTTP 502')
+    await fiber.dispose()
+  })
+
+  it('registers both dictionaries under its own namespace and releases them with the fiber', async () => {
+    vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ apps: [] }), { status: 200 })))
+    const { ctx, fiber } = await bench()
+    ctx.locale.setLocale('zh')
+    const translate = ctx.locale.bind(NS)
+    expect(translate('menu.aria')).toBe(zh['menu.aria'])
+    ctx.locale.setLocale('en')
+    expect(translate('menu.aria')).toBe(en['menu.aria'])
+    await fiber.dispose()
+    expect(translate('menu.aria')).not.toBe(en['menu.aria'])
+  })
+
+  it('keeps the English dictionary key-identical to the Chinese source of truth', () => {
+    expect(Object.keys(en).sort()).toEqual(Object.keys(zh).sort())
+  })
+})
+
+describe('ui-open-in-app node half', () => {
+  it('the node apply is an inert loader seat', () => {
+    expect(() => { nodeApply() }).not.toThrow()
+  })
+})

+ 84 - 0
packages/client/ui-open-in-app/tests/controller.client.spec.ts

@@ -0,0 +1,84 @@
+/** Controller wire behavior: host-base resolution, availability filtering, and launch errors. */
+
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { OpenInAppController } from '../src/client/controller.ts'
+
+afterEach(() => {
+  vi.unstubAllGlobals()
+})
+
+function jsonResponse(payload: unknown, status = 200): Response {
+  return new Response(JSON.stringify(payload), { status })
+}
+
+describe('OpenInAppController availability', () => {
+  it('starts without a platform-specific choice', () => {
+    const controller = new OpenInAppController(async () => jsonResponse({ apps: [] }))
+    expect(controller.choice.getSnapshot()).toBe('')
+  })
+
+  it('shares one availability read across concurrent loads', async () => {
+    const fetcher = vi.fn(async () => jsonResponse({ apps: ['finder'] }))
+    const controller = new OpenInAppController(fetcher)
+    await Promise.all([controller.load(), controller.load()])
+    await controller.load()
+    expect(fetcher).toHaveBeenCalledOnce()
+    expect(controller.apps.getSnapshot()).toEqual(['finder'])
+  })
+
+  it('publishes an empty list for a non-OK availability answer and for a non-array payload', async () => {
+    const failing = new OpenInAppController(async () => jsonResponse({}, 500))
+    await failing.load()
+    expect(failing.apps.getSnapshot()).toEqual([])
+
+    const malformed = new OpenInAppController(async () => jsonResponse({ apps: 'nope' }))
+    await malformed.load()
+    expect(malformed.apps.getSnapshot()).toEqual([])
+  })
+
+  it('resolves routes against the page origin when the page has one', async () => {
+    vi.stubGlobal('location', { origin: 'http://dsh.example:8080' })
+    const fetcher = vi.fn(async (input: string | URL) => { void input; return jsonResponse({ apps: [] }) })
+    const controller = new OpenInAppController(fetcher)
+    await controller.load()
+    expect(String(fetcher.mock.calls[0]?.[0])).toBe('http://dsh.example:8080/open-in-app/apps')
+  })
+
+  it('falls back to the internal host base under a null origin', async () => {
+    vi.stubGlobal('location', { origin: 'null' })
+    const fetcher = vi.fn(async (input: string | URL) => { void input; return jsonResponse({ apps: [] }) })
+    const controller = new OpenInAppController(fetcher)
+    await controller.load()
+    expect(String(fetcher.mock.calls[0]?.[0])).toBe('http://dsh.internal/open-in-app/apps')
+  })
+})
+
+describe('OpenInAppController launching', () => {
+  it('restores the chosen app from the open-in-app storage key', () => {
+    const values = new Map<string, string>()
+    vi.stubGlobal('localStorage', {
+      getItem: (key: string) => values.get(key) ?? null,
+      setItem: (key: string, value: string) => { values.set(key, value) },
+    })
+    const controller = new OpenInAppController(async () => jsonResponse({ apps: [] }))
+    controller.choose('cursor')
+    expect(controller.choice.getSnapshot()).toBe('cursor')
+    expect(values.get('dsh.open-in-app.choice')).toBe('"cursor"')
+    const reloaded = new OpenInAppController(async () => jsonResponse({ apps: [] }))
+    expect(reloaded.choice.getSnapshot()).toBe('cursor')
+  })
+
+  it('posts the launch body and surfaces HTTP failures', async () => {
+    const fetcher = vi.fn(async (input: string | URL, init?: RequestInit) => { void input; void init; return jsonResponse({ ok: true }) })
+    const controller = new OpenInAppController(fetcher)
+    await controller.launch('cursor', '/w/dir')
+    expect(fetcher.mock.calls[0]?.[1]).toMatchObject({
+      method: 'POST',
+      headers: { 'content-type': 'application/json' },
+      body: JSON.stringify({ app: 'cursor', path: '/w/dir' }),
+    })
+
+    const failing = new OpenInAppController(async () => jsonResponse({}, 404))
+    await expect(failing.launch('cursor', '/w/dir')).rejects.toThrow('open failed: HTTP 404')
+  })
+})

+ 243 - 0
packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx

@@ -0,0 +1,243 @@
+// @vitest-environment jsdom
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { cleanup, fireEvent, render, screen, waitFor, act } from '@testing-library/react'
+import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
+import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import { OpenInAppAction, type OpenInAppActionProps } from '../src/client/OpenInAppAction.tsx'
+import { zh } from '../src/client/locales.ts'
+
+afterEach(() => {
+  cleanup()
+  vi.restoreAllMocks()
+  vi.useRealTimers()
+})
+
+const SESSION = 'session' as SessionId
+const t: OpenInAppActionProps['t'] = makeTranslate(zh)
+
+interface Bench {
+  props: OpenInAppActionProps
+  launch: ReturnType<typeof vi.fn>
+  choose: ReturnType<typeof vi.fn>
+}
+
+function bench(over: {
+  apps?: readonly string[] | null
+  choice?: string
+  cwd?: string
+  launch?: (appId: string, path: string) => Promise<void>
+} = {}): Bench {
+  const state = {
+    ids: [SESSION],
+    byId: over.cwd === undefined ? {} : { [SESSION]: { cwd: over.cwd } },
+    current: SESSION,
+    phase: 'ready',
+    subagentsByParent: {},
+    jobsBySession: {},
+    currentAddress: undefined,
+  } as unknown as SessionListState
+  const apps = createSnapshotStore<readonly string[] | null>(over.apps ?? null)
+  const choice = createSnapshotStore<string>(over.choice ?? '')
+  const launch = vi.fn(over.launch ?? (async () => {}))
+  const choose = vi.fn()
+  function useSessions<T>(select: (snapshot: SessionListState) => T): T {
+    return select(state)
+  }
+  function useSelector<T, R>(source: { getSnapshot(): T }): (select: (value: T) => R) => R {
+    return select => select(source.getSnapshot())
+  }
+  const props = {
+    sessionId: SESSION,
+    useSessions,
+    useOpenInAppApps: useSelector(apps),
+    useOpenInAppChoice: useSelector(choice),
+    launch,
+    choose,
+    iconUrl: (appId: string) => `/open-in-app/icon/${appId}`,
+    t,
+  } as unknown as OpenInAppActionProps
+  return { props, launch, choose }
+}
+
+describe('OpenInAppAction visibility', () => {
+  it('renders nothing before availability arrives, with no apps, without a cwd, and for unnameable ids', () => {
+    for (const over of [
+      { apps: null, cwd: '/w' },
+      { apps: [], cwd: '/w' },
+      { apps: ['finder'] },
+      { apps: ['finder'], cwd: '' },
+      { apps: ['someday-an-app'], cwd: '/w' },
+    ] as const) {
+      const { container } = render(<OpenInAppAction {...bench(over).props} />)
+      expect(container.innerHTML).toBe('')
+      cleanup()
+    }
+  })
+
+  it('shows the remembered choice, falling back to the first available app when it is gone', () => {
+    render(<OpenInAppAction {...bench({ apps: ['finder', 'cursor'], choice: 'cursor', cwd: '/w' }).props} />)
+    expect(screen.getByRole('button', { name: zh['open.title'].replace('{app}', 'Cursor') })).toBeDefined()
+    cleanup()
+
+    render(<OpenInAppAction {...bench({ apps: ['finder', 'cursor'], choice: 'vscode', cwd: '/w' }).props} />)
+    expect(screen.getByRole('button', { name: zh['open.title'].replace('{app}', zh['app.finder']) })).toBeDefined()
+  })
+})
+
+describe('OpenInAppAction launching', () => {
+  it('launches without painting the busy dress when the launch settles quickly', async () => {
+    let resolve: () => void = () => {}
+    const b = bench({
+      apps: ['finder'],
+      cwd: '/w/dir',
+      launch: () => new Promise((r) => { resolve = r }),
+    })
+    render(<OpenInAppAction {...b.props} />)
+    const main = screen.getByRole('button', { name: zh['open.title'].replace('{app}', zh['app.finder']) })
+    fireEvent.click(main)
+    expect(b.launch).toHaveBeenCalledWith('finder', '/w/dir')
+    // No flash: the button keeps its idle dress while the launch is fast.
+    expect((main as HTMLButtonElement).disabled).toBe(false)
+    expect(main.getAttribute('data-state')).toBe('idle')
+    // A second click while in flight is ignored rather than double-launching.
+    fireEvent.click(main)
+    expect(b.launch).toHaveBeenCalledTimes(1)
+
+    resolve()
+    await waitFor(() => {
+      fireEvent.click(main)
+      expect(b.launch).toHaveBeenCalledTimes(2)
+    })
+  })
+
+  it('dresses a slow launch as busy, then shows the error state on failure', async () => {
+    vi.useFakeTimers()
+    let reject: (error: Error) => void = () => {}
+    const b = bench({
+      apps: ['finder'],
+      cwd: '/w/dir',
+      launch: () => new Promise((_, r) => { reject = r }),
+    })
+    render(<OpenInAppAction {...b.props} />)
+    const main = screen.getByRole('button', { name: zh['open.title'].replace('{app}', zh['app.finder']) })
+    fireEvent.click(main)
+
+    // The busy dress appears only after the launch has taken a while.
+    act(() => { vi.advanceTimersByTime(300) })
+    expect((main as HTMLButtonElement).disabled).toBe(true)
+    expect(main.getAttribute('data-state')).toBe('busy')
+
+    act(() => { reject(new Error('launch failed')) })
+    await act(async () => { await vi.runOnlyPendingTimersAsync() })
+    expect(screen.getByRole('button', { name: zh['open.title'].replace('{app}', zh['app.finder']) })).toBeDefined()
+  })
+
+  it('shows the error state and decays back to idle after a fast failure', async () => {
+    const b = bench({
+      apps: ['finder'],
+      cwd: '/w/dir',
+      launch: () => Promise.reject(new Error('launch failed')),
+    })
+    render(<OpenInAppAction {...b.props} />)
+    const main = screen.getByRole('button', { name: zh['open.title'].replace('{app}', zh['app.finder']) })
+    fireEvent.click(main)
+    await waitFor(() => {
+      expect(screen.getByRole('button', { name: zh['open.error'] })).toBeDefined()
+    })
+    // The error state decays back to idle.
+    await waitFor(() => {
+      expect(screen.getByRole('button', { name: zh['open.title'].replace('{app}', zh['app.finder']) })).toBeDefined()
+    }, { timeout: 4_000 })
+  })
+
+  it('shows the product tooltip on hover instead of a native title', async () => {
+    render(<OpenInAppAction {...bench({ apps: ['finder'], cwd: '/w/dir' }).props} />)
+    const main = screen.getByRole('button', { name: zh['open.title'].replace('{app}', zh['app.finder']) })
+    expect(main.getAttribute('title')).toBeNull()
+    fireEvent.mouseEnter(main)
+    expect(await screen.findByText(zh['open.tooltip'])).toBeDefined()
+    fireEvent.mouseLeave(main)
+    await waitFor(() => {
+      expect(screen.queryByText(zh['open.tooltip'])).toBeNull()
+    })
+  })
+
+  it('opens the menu from the chevron, launches and persists a picked app', async () => {
+    const b = bench({ apps: ['finder', 'cursor', 'terminal'], cwd: '/w/dir' })
+    render(<OpenInAppAction {...b.props} />)
+    fireEvent.click(screen.getByRole('button', { name: zh['menu.toggle'] }))
+    const cursorItem = await screen.findByText('Cursor')
+    fireEvent.click(cursorItem)
+    expect(b.choose).toHaveBeenCalledWith('cursor')
+    expect(b.launch).toHaveBeenCalledWith('cursor', '/w/dir')
+  })
+
+  it('ignores a menu pick while a launch is in flight', async () => {
+    let resolve: () => void = () => {}
+    const b = bench({
+      apps: ['finder', 'cursor'],
+      cwd: '/w/dir',
+      launch: () => new Promise((r) => { resolve = r }),
+    })
+    render(<OpenInAppAction {...b.props} />)
+    fireEvent.click(screen.getByRole('button', { name: zh['open.title'].replace('{app}', zh['app.finder']) }))
+    expect(b.launch).toHaveBeenCalledTimes(1)
+    fireEvent.click(screen.getByRole('button', { name: zh['menu.toggle'] }))
+    fireEvent.click(await screen.findByText('Cursor'))
+    // Mid-flight the pick is ignored whole: no persisted choice, no launch.
+    expect(b.choose).not.toHaveBeenCalled()
+    expect(b.launch).toHaveBeenCalledTimes(1)
+    resolve()
+    await act(async () => {})
+  })
+
+  it('clears a pending error decay when a retry starts', async () => {
+    vi.useFakeTimers()
+    const outcomes: Array<() => Promise<void>> = [
+      () => Promise.reject(new Error('launch failed')),
+      // The retry stays in flight past the original decay deadline.
+      () => new Promise(() => {}),
+    ]
+    const b = bench({
+      apps: ['finder'],
+      cwd: '/w/dir',
+      launch: () => (outcomes.shift() ?? (() => Promise.resolve()))(),
+    })
+    render(<OpenInAppAction {...b.props} />)
+    fireEvent.click(screen.getByRole('button', { name: zh['open.title'].replace('{app}', zh['app.finder']) }))
+    await act(async () => {})
+    fireEvent.click(screen.getByRole('button', { name: zh['open.error'] }))
+    // Past the first failure's 2s decay: the stale timer must not flip the
+    // in-flight retry's busy dress back to a clickable idle button.
+    act(() => { vi.advanceTimersByTime(2_500) })
+    const main = screen.getByRole('button', { name: zh['open.title'].replace('{app}', zh['app.finder']) })
+    expect(main.getAttribute('data-state')).toBe('busy')
+    expect((main as HTMLButtonElement).disabled).toBe(true)
+  })
+
+  it('closes an open menu on Escape without launching', async () => {
+    const b = bench({ apps: ['finder', 'terminal'], cwd: '/w/dir' })
+    render(<OpenInAppAction {...b.props} />)
+    fireEvent.click(screen.getByRole('button', { name: zh['menu.toggle'] }))
+    await screen.findByText(zh['app.terminal'])
+    fireEvent.keyDown(document, { key: 'Escape' })
+    await waitFor(() => {
+      expect(screen.queryByText(zh['app.terminal'])).toBeNull()
+    })
+    expect(b.launch).not.toHaveBeenCalled()
+  })
+
+  it('falls back to the generic icon after a failed image load', async () => {
+    const b = bench({ apps: ['terminal'], cwd: '/w/dir' })
+    const { container } = render(<OpenInAppAction {...b.props} />)
+    const img = container.querySelector('img')
+    expect(img?.getAttribute('src')).toBe('/open-in-app/icon/terminal')
+    if (img !== null) fireEvent.error(img)
+    await waitFor(() => {
+      expect(container.querySelector('img')).toBeNull()
+      expect(container.querySelector('svg rect')).not.toBeNull()
+    })
+  })
+})

+ 42 - 0
packages/client/ui-open-in-app/tsconfig.json

@@ -0,0 +1,42 @@
+{
+  "extends": "../../../tsconfig.base.client.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../api/session-controller/tsconfig.client.json"
+    },
+    {
+      "path": "../../host/open-in-app"
+    },
+    {
+      "path": "../locale"
+    },
+    {
+      "path": "../store"
+    },
+    {
+      "path": "../ui-conversation"
+    },
+    {
+      "path": "../ui-primitives"
+    },
+    {
+      "path": "../ui-renderer"
+    },
+    {
+      "path": "../ui-session"
+    },
+    {
+      "path": "../ui-slots"
+    }
+  ]
+}

+ 3 - 0
packages/client/ui-open-in-app/tsdown.config.ts

@@ -0,0 +1,3 @@
+import { clientBundle } from '../tsdown.client.ts'
+
+export default clientBundle('@deepseek-ai/dsh-client-ui-open-in-app', ['lib/types/index.js'])

+ 5 - 0
packages/client/ui-primitives/src/Menu.module.css

@@ -190,6 +190,11 @@
   background: transparent;
 }
 
+/* Fill-mode selection: the row holds the hover fill instead of a check. */
+.selectedFill {
+  background: var(--dsw-alias-interactive-bg-hover);
+}
+
 /* Destructive row: error text/icon, danger hover fill. */
 .danger {
   color: var(--dsw-alias-state-error-primary);

+ 9 - 4
packages/client/ui-primitives/src/Menu.tsx

@@ -75,9 +75,13 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
  * scroll/resize; return null to skip placement for that frame.
  * @param props.footer - rows pinned below the scrolling items area, separated
  * by a hairline; they stay visible while the items above scroll.
+ * @param props.selection - how a selected row is marked: a trailing check
+ * (`'check'`, default — figma .Menu_cell) or the hover fill held on the row
+ * with no check (`'fill'`, for icon-labelled rows where a trailing glyph
+ * crowds the cell).
  * @returns anchor wrapper with the conditional list.
  */
-export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, getAnchorRect, footer, className }: {
+export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, selection = 'check', getAnchorRect, footer, className }: {
   open: boolean
   anchor: ReactNode
   items: readonly MenuEntry[]
@@ -92,6 +96,7 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o
   closeOnPointerLeave?: boolean
   dense?: boolean
   compact?: boolean
+  selection?: 'check' | 'fill'
   getAnchorRect?: () => DOMRect | null
   className?: string
 }) {
@@ -209,7 +214,7 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o
         <button
           type="button"
           role="menuitem"
-          className={clsx(css.item, selected && css.selected, entry.danger === true && css.danger)}
+          className={clsx(css.item, selected && (selection === 'fill' ? css.selectedFill : css.selected), entry.danger === true && css.danger)}
           disabled={entry.disabled}
           aria-haspopup={hasSub ? 'menu' : undefined}
           aria-expanded={hasSub ? subOpen : undefined}
@@ -224,8 +229,8 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o
         >
           {entry.icon !== undefined && <span className={css.itemIcon}>{entry.icon}</span>}
           <span className={css.itemLabel}>{entry.label}</span>
-          {/* Selection marker is a trailing check (figma .Menu_cell), not a fill. */}
-          {selected && <IconCheckOutline16 className={css.check} />}
+          {/* Selection marker is a trailing check (figma .Menu_cell) unless the fill mode carries it. */}
+          {selected && selection === 'check' && <IconCheckOutline16 className={css.check} />}
         </button>
         {subOpen && entry.submenu !== undefined && (
           <div className={clsx(css.submenu, compact && css.compactList)} role="menu">

+ 18 - 0
packages/client/ui-primitives/tests/atoms.client.spec.tsx

@@ -120,6 +120,24 @@ describe('Menu', () => {
     fireEvent.keyDown(document, { key: 'a' })
   })
 
+  it('fill selection holds the row fill instead of a trailing check', () => {
+    render(
+      <Menu
+        open
+        selection="fill"
+        anchor={<span>trigger</span>}
+        items={items}
+        selectedId="a"
+        onSelect={() => {}}
+        onClose={() => {}}
+      />)
+    const selected = screen.getByRole('menuitem', { name: 'Alpha' })
+    expect(selected.querySelector('svg')).toBeNull()
+    expect(selected.className).toMatch(/selectedFill/)
+    const other = screen.getByRole('menuitem', { name: 'Beta' })
+    expect(other.className).not.toMatch(/selectedFill/)
+  })
+
   it('renders a leading icon and a separator between groups', () => {
     render(
       <Menu

+ 2 - 2
packages/core/agent/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/core/agent/README.md
-README.md: 37d5d24192ed09bfdeb5210cdd9fcf2c2bd058e2
-README.zh.md: bb56687edaa9debc937908ff26770bbce92a8a80
+README.md: f413abd855be17fde2e38997df8269d1ca2670b5
+README.zh.md: 74797894177994cb3098aa67e969f45ced357634

+ 5 - 5
packages/core/agent/README.md

@@ -133,11 +133,11 @@ The package-level contract is enough for most consumers; read these when you nee
 
 #### What the model sees
 
-`followup`, `steer`, and `inject` feed the owning session as identified user-role messages; accepted content becomes part of the derived history the model reads on later steps. `agent/pre-step` and the other declared events let plugins reject a proposed step or add durable request material; this interface contributes no fixed prose itself.
+`followup`, `steer`, and `inject` feed the owning session as identified user-role messages; accepted content becomes part of the derived history the model reads on later steps. `agent/pre-step` and the other declared events let plugins reject a proposed step or add durable request material. `installModelSelection` adds `[model changed: assistant turns above this point were generated by <previous>; the session continues with <next>]` to the first step assembled for a different provider/model route that would send a model request; provider names appear only when the switch crosses providers, and reasoning-effort-only changes add nothing. An empty first decision and a decision that removes offered messages remain no-request results. If a request step fails before logging its header, the next request step receives the notice again because the durable previous route has not changed.
 
 #### Token effect
 
-Accepted content becomes retained history or a repeated session prefix; blocked content contributes no request tokens. Size is caller- and plugin-dependent.
+Accepted content becomes retained history or a repeated session prefix; blocked content contributes no request tokens. Each emitted model-switch notice adds its text to retained history. Size is caller- and plugin-dependent.
 
 #### KV Cache effect
 
@@ -147,15 +147,15 @@ Accepted history and steering are append-only; a blocked submission sends no req
 
 #### What the model sees
 
-Registrations through `agent.ctx` can shadow prompt sections or tools and can install agent-only interceptors during unpublished setup, so one agent sees a different prompt and tool set than its neighbors.
+Registrations through `agent.ctx` can shadow prompt sections or tools and can install agent-only interceptors during unpublished setup, so one agent sees a different prompt and tool set than its neighbors. Model selection captures one provider/model/effort value before prompt assembly and applies it to the same step's request; a later concurrent change waits for another step.
 
 #### Token effect
 
-The package adds zero tokens itself; scoped contributions affect only that agent and disappear on disposal.
+Each provider/model switch adds one short retained user-role notice. Other scoped contributions affect only that agent and disappear on disposal.
 
 #### KV Cache effect
 
-Prefix-stable while an agent's scoped registrations are unchanged. Setup or reload that changes prompt sections, tool definitions, or request listeners may invalidate reuse from the first affected request token.
+The switch notice appends after the previous history, preserving that prefix, while the route change can prevent the new provider or model from reusing it. Setup or reload that changes prompt sections, tool definitions, or request listeners may invalidate reuse from the first affected request token.
 
 ## Known Limitations and Deferred Work
 

+ 5 - 5
packages/core/agent/README.zh.md

@@ -133,11 +133,11 @@ await handle.agent.whenIdle()
 
 #### 模型看到什么
 
-`followup`、`steer` 与 `inject` 以带标识的 user 角色消息馈送所属会话;被接纳的内容成为模型在后续步骤中读取的派生历史的一部分。`agent/pre-step` 与其他已声明事件让插件能够拒绝拟进入的步骤或添加持久请求材料;此接口本身不贡献固定文案
+`followup`、`steer` 与 `inject` 以带标识的 user 角色消息馈送所属会话;被接纳的内容成为模型在后续步骤中读取的派生历史的一部分。`agent/pre-step` 与其他已声明事件让插件能够拒绝拟进入的步骤或添加持久请求材料。`installModelSelection` 会在首次为不同提供方/模型路由组装且原本会发出模型请求的步骤中加入 `[model changed: assistant turns above this point were generated by <previous>; the session continues with <next>]`;仅跨提供方切换时显示提供方名称,只改变推理强度时不添加消息。首次空决策与移除待处理消息后得到的空决策都不会产生请求。如果请求步骤在记录 header 前失败,持久记录中的先前路由没有变化,所以下一个请求步骤会再次收到提示
 
 #### Token 影响
 
-被接纳内容成为保留历史,或成为每次请求重复的会话前缀;被阻止内容不贡献请求 token。大小取决于调用方与插件。
+被接纳内容成为保留历史,或成为每次请求重复的会话前缀;被阻止内容不贡献请求 token。每条实际发出的模型切换提示都会把对应文本加入保留历史。大小取决于调用方与插件。
 
 #### KV Cache 影响
 
@@ -147,15 +147,15 @@ await handle.agent.whenIdle()
 
 #### 模型看到什么
 
-通过 `agent.ctx` 进行的注册可以遮蔽提示词段或工具,也可以在未发布 setup 期间安装仅适用于该 agent 的拦截器,因此一个 agent 看到的提示词与工具集会与其邻居不同。
+通过 `agent.ctx` 进行的注册可以遮蔽提示词段或工具,也可以在未发布 setup 期间安装仅适用于该 agent 的拦截器,因此一个 agent 看到的提示词与工具集会与其邻居不同。模型选择会在提示词组装前捕获一次提供方/模型/推理强度值,并将其应用到同一步骤的请求;之后发生的并发变更等待下一个步骤。
 
 #### Token 影响
 
-此包自身不增加 token;带作用域贡献只影响该 agent,并在 dispose 时消失。
+每次提供方/模型切换会增加一条简短且保留在历史中的 user 角色提示。其他带作用域贡献只影响该 agent,并在 dispose 时消失。
 
 #### KV Cache 影响
 
-只要 agent 的作用域注册不变,前缀就保持稳定。改变提示词段、工具定义或请求监听器的 setup 或 reload,可能从第一个受影响的请求 token 起使复用失效。
+切换提示追加在先前历史之后,因此保留该前缀;路由变更可能使新的提供方或模型无法复用此前缀。改变提示词段、工具定义或请求监听器的 setup 或 reload,可能从第一个受影响的请求 token 起使复用失效。
 
 ## 已知限制与延期工作
 

+ 54 - 2
packages/core/agent/src/model-selection.ts

@@ -4,7 +4,13 @@
  */
 
 import type { Context } from '@deepseek-ai/cordis'
-import type { LlmCallConfig, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
+import {
+  boundContextSummary,
+  createUserMessage,
+  type LlmCallConfig,
+  type ReasoningEffortId,
+} from '@deepseek-ai/dsh-llm'
+import type { PreStepDecision } from './runtime-types.ts'
 
 /** Complete provider, model, and optional reasoning effort selected for one live Agent. */
 export interface ModelSelection {
@@ -24,6 +30,31 @@ export interface ModelSelectionRef {
   assembled: ModelSelection | undefined
 }
 
+function sameRoute(left: ModelSelection, right: ModelSelection): boolean {
+  return left.provider === right.provider && left.model === right.model
+}
+
+function routeLabel(route: ModelSelection, other: ModelSelection): string {
+  return route.provider === other.provider ? route.model : `${route.provider}/${route.model}`
+}
+
+function modelSwitchNotice(previous: ModelSelection, selected: ModelSelection) {
+  const from = routeLabel(previous, selected)
+  const to = routeLabel(selected, previous)
+  return createUserMessage({
+    content: [{
+      type: 'text' as const,
+      text: `[model changed: assistant turns above this point were generated by ${from}; the session continues with ${to}]`,
+    }],
+    source: {
+      kind: 'plugin' as const,
+      plugin: 'model-selection',
+      form: 'notice' as const,
+      summary: boundContextSummary(`${from} → ${to}`),
+    },
+  })
+}
+
 /**
  * Couple one mutable selection to Agent-scoped prompt assembly and request routing.
  * Prompt assembly snapshots the selected model before delegating, then applies
@@ -32,9 +63,15 @@ export interface ModelSelectionRef {
  * surfaces. An absent selected effort clears any inherited effort, restoring
  * the selected model's provider/default behavior.
  *
+ * A provider/model change appends a durable user-role notice to the next
+ * admitted request. It compares the assembled selection with the latest
+ * request header; effort-only changes and empty no-request decisions add no
+ * notice. Failure before header persistence repeats the notice on the next
+ * request.
+ *
  * @param agentCtx - The selected Agent's scoped context.
  * @param selection - Mutable selection owned by the calling entry point.
- * @returns Disposer for both scoped waterfall listeners.
+ * @returns Disposer for all scoped waterfall listeners.
  */
 export function installModelSelection(agentCtx: Context, selection: ModelSelectionRef): () => void {
   const disposeAssembly = agentCtx.on('system-prompt/assemble', async (_assembly, _context, next) => {
@@ -68,8 +105,23 @@ export function installModelSelection(agentCtx: Context, selection: ModelSelecti
       }
     },
   )
+  const disposeNotice = agentCtx.on(
+    'agent/pre-step',
+    async ({ agent, messages, signal, step }, next): Promise<PreStepDecision> => {
+      const decision = await next()
+      if (decision.kind === 'reject' || signal.aborted) return decision
+      // The loop skips an empty first step and an emptied offered continuation.
+      if (decision.messages.length === 0 && (step === 1 || messages.length > 0)) return decision
+      const selected = selection.assembled
+      const previous = agent.session.requestHeader()?.config
+      if (selected === undefined || previous === undefined || sameRoute(selected, previous)) return decision
+      return { ...decision, messages: [...decision.messages, modelSwitchNotice(previous, selected)] }
+    },
+    { prepend: true },
+  )
   return () => {
     disposeAssembly()
     disposeRequest()
+    disposeNotice()
   }
 }

+ 133 - 3
packages/core/agent/tests/model-selection.spec.ts

@@ -5,17 +5,79 @@ import {
   agentEvents,
   installModelSelection,
   type Agent,
+  type ModelSelection,
   type ModelSelectionRef,
 } from '../src/index.ts'
-import { ReasoningEffortId, type LlmCallConfig } from '@deepseek-ai/dsh-llm'
+import {
+  createUserMessage,
+  ReasoningEffortId,
+  type LlmCallConfig,
+  type UserMessage,
+} from '@deepseek-ai/dsh-llm'
+import { Session, SessionId } from '@deepseek-ai/dsh-session'
+
+const SIGNAL = new AbortController().signal
+const INPUT = createUserMessage({
+  content: [{ type: 'text', text: 'continue' }],
+  source: { kind: 'user' },
+})
+
+function createAgent(): Agent {
+  return { session: Session.create(SessionId('model-selection')) } as Agent
+}
+
+function expectedNotice(from: string, to: string) {
+  return {
+    content: [{
+      type: 'text',
+      text: `[model changed: assistant turns above this point were generated by ${from}; the session continues with ${to}]`,
+    }],
+    source: { kind: 'plugin', plugin: 'model-selection', form: 'notice', summary: `${from} → ${to}` },
+  }
+}
+
+async function switchHarness(current: ModelSelection, previous?: ModelSelection) {
+  const ctx = new Context()
+  await ctx.plugin(SystemPrompt)
+  const selection: ModelSelectionRef = { current, assembled: undefined }
+  const dispose = installModelSelection(ctx, selection)
+  const agent = createAgent()
+  if (previous !== undefined) {
+    agent.session.append('request/header', { header: { config: previous }, reason: 'initial' })
+  }
+  await ctx.systemPrompt.assemble()
+  return { agent, ctx, dispose, selection }
+}
+
+async function preStep(
+  ctx: Context,
+  agent: Agent,
+  {
+    messages = [INPUT],
+    offered = [INPUT],
+    step = 1,
+    signal = SIGNAL,
+  }: {
+    messages?: UserMessage[]
+    offered?: UserMessage[]
+    step?: number
+    signal?: AbortSignal
+  } = {},
+) {
+  return agentEvents(ctx, agent).waterfall(
+    'agent/pre-step',
+    { turn: 1, step, messages: offered, signal },
+    () => Promise.resolve({ kind: 'enter' as const, messages }),
+  )
+}
 
 describe('installModelSelection()', () => {
-  it('snapshots prompt variables and request routing together, then disposes both listeners', async () => {
+  it('snapshots prompt variables and request routing together, then disposes its listeners', async () => {
     const ctx = new Context()
     await ctx.plugin(SystemPrompt)
     const selection: ModelSelectionRef = { current: undefined, assembled: undefined }
     const dispose = installModelSelection(ctx, selection)
-    const agent = {} as Agent
+    const agent = createAgent()
     const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 }
     const signal = new AbortController().signal
 
@@ -58,4 +120,72 @@ describe('installModelSelection()', () => {
     )).resolves.toBe(seed)
     await ctx.fiber.dispose()
   })
+
+  it('announces same-provider and cross-provider route changes from the assembled selection', async () => {
+    const { agent, ctx, dispose, selection } = await switchHarness(
+      { provider: 'alpha', model: 'a1' },
+      { provider: 'alpha', model: 'a0' },
+    )
+    await expect(preStep(ctx, agent)).resolves.toMatchObject({
+      messages: [INPUT, expectedNotice('a0', 'a1')],
+    })
+
+    selection.current = { provider: 'beta', model: 'b1' }
+    await ctx.systemPrompt.assemble()
+    selection.current = { provider: 'alpha', model: 'a2' }
+    await expect(preStep(ctx, agent)).resolves.toMatchObject({
+      messages: [INPUT, expectedNotice('alpha/a0', 'beta/b1')],
+    })
+    await ctx.systemPrompt.assemble()
+    await expect(preStep(ctx, agent)).resolves.toMatchObject({
+      messages: [INPUT, expectedNotice('a0', 'a2')],
+    })
+
+    dispose()
+    await ctx.fiber.dispose()
+  })
+
+  it('does not announce initial, same-route, effort-only, rejected, aborted, or disposed steps', async () => {
+    const { agent, ctx, dispose, selection } = await switchHarness({ provider: 'alpha', model: 'a0' })
+    await expect(preStep(ctx, agent)).resolves.toMatchObject({ kind: 'enter', messages: [INPUT] })
+    agent.session.append('request/header', {
+      header: { config: { provider: 'alpha', model: 'a0' } }, reason: 'initial',
+    })
+    selection.current = {
+      provider: 'alpha',
+      model: 'a0',
+      reasoningEffort: ReasoningEffortId('high'),
+    }
+    await ctx.systemPrompt.assemble()
+    await expect(preStep(ctx, agent)).resolves.toMatchObject({ kind: 'enter', messages: [INPUT] })
+
+    selection.current = { provider: 'alpha', model: 'a1' }
+    await ctx.systemPrompt.assemble()
+    const rejected = await agentEvents(ctx, agent).waterfall(
+      'agent/pre-step',
+      { turn: 1, step: 1, messages: [], signal: SIGNAL },
+      () => Promise.resolve({ kind: 'reject' as const }),
+    )
+    expect(rejected).toEqual({ kind: 'reject' })
+    const aborted = new AbortController()
+    aborted.abort()
+    await expect(preStep(ctx, agent, { signal: aborted.signal })).resolves.toMatchObject({ messages: [INPUT] })
+
+    dispose()
+    await expect(preStep(ctx, agent)).resolves.toMatchObject({ kind: 'enter', messages: [INPUT] })
+    await ctx.fiber.dispose()
+  })
+
+  it('preserves empty no-call decisions and announces an empty tool continuation', async () => {
+    const { agent, ctx } = await switchHarness(
+      { provider: 'alpha', model: 'a1' },
+      { provider: 'alpha', model: 'a0' },
+    )
+    await expect(preStep(ctx, agent, { messages: [] })).resolves.toEqual({ kind: 'enter', messages: [] })
+    await expect(preStep(ctx, agent, { messages: [], step: 2 })).resolves.toEqual({ kind: 'enter', messages: [] })
+    await expect(preStep(ctx, agent, { messages: [], offered: [], step: 2 })).resolves.toMatchObject({
+      messages: [{ source: { summary: 'a0 → a1' } }],
+    })
+    await ctx.fiber.dispose()
+  })
 })

+ 2 - 2
packages/e2b/subprocess-e2b/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/e2b/subprocess-e2b/README.md
-README.md: 28f1b3410781fd87e40527d70e823bd9111d57e7
-README.zh.md: 3d9a35dea74126ae4cc5d381944b2fb5a86f61ee
+README.md: 8222f81e3b077aabf5c4dd4f2c4d58e501b2846a
+README.zh.md: 449ca331f96cb7799535a94c7b4d7583c2447ead

+ 4 - 5
packages/e2b/subprocess-e2b/README.md

@@ -29,7 +29,7 @@ Use this package when the agent's shell commands and terminals should run inside
 
 ### When to choose it
 
-Choose it when a composition already uses the E2B sandbox and you want commands and terminals to run there. Choose the local subprocess package for host execution. Tooling that needs a process id immediately — for example the ACP child backend — cannot use this package.
+Choose it when a composition already uses the E2B sandbox and you want commands and terminals to run there. Choose the local subprocess package for host execution.
 
 ### Configuration
 
@@ -71,7 +71,7 @@ This section explains the design decisions behind the provider and points at the
 
 ### Design philosophy
 
-- **Deferred remote identity.** The synchronous seam never blocks on the network: the handle publishes its real process-group id asynchronously, and the wrapper's private files are the authority for pid, exit code, and spill validity.
+- **Provider-private remote identity.** The synchronous seam never blocks on the network. Private wrapper files asynchronously publish a process-group identity for stdin, observation, termination, and quiescence checks, together with the direct exit code and spill validity; that identity is not the requested target PID.
 - **One teardown ladder.** Termination, rollback, and disposal share one process-group signal path — `SIGTERM`, then `SIGKILL` plus the SDK kill fallback — and treat proven quiescence as final.
 - **Environment is explicit.** Nothing from the host and nothing credential-shaped enters the sandbox implicitly; every ambient value is scrubbed and every `spec.env` entry is an explicit opt-in.
 
@@ -91,9 +91,9 @@ This section explains the design decisions behind the provider and points at the
 
 The bootstrap resolves its own tools from the sandbox PATH, refuses any missing or non-executable path, execs through `env -i` and `setsid --wait`, publishes the process-group id and exit code to private files beneath `ctx.e2b.runtimeRoot/processes`, and redirects stdout and stderr through base64 encoders that emit a reserved completion frame; `tee` and `head -c` bound optional spill files.
 
-### Process identity and publication
+### Private process identity and publication
 
-The synchronous seam returns a handle immediately while the command starts asynchronously; `pid` stays `-1` until the wrapper publishes its process-group id and the adapter validates it, and stdin plus ordinary observation wait for that publication. A startup signal aborts environment and private-state preparation before allocation; once allocation begins, cancellation waits for a provisional SDK handle it can clean.
+The synchronous seam returns a handle immediately while the command starts asynchronously. The wrapper publishes a private process-group ID for stdin, observation, termination, and quiescence checks, but that ID is not the requested target PID. A startup signal aborts environment and private-state preparation before allocation; once allocation begins, cancellation waits for a provisional SDK handle it can clean.
 
 ### Environment boundary
 
@@ -142,7 +142,6 @@ No direct invalidation: the consumer seams own any request-prefix changes; this
 These limits define when the provider is a poor fit or needs special operational care. They are current package constraints, not a task backlog.
 
 - **The SDK still retains complete command output in host memory** — E2B `CommandHandle.stdout` and `.stderr` accumulate the base64 transport even when this adapter exposes bounded raw-byte tails, so the subprocess seam's normal host-memory bound is not achieved and transport retention is larger than the source stream.
-- **Synchronous-PID consumers are unsupported** — `pid` remains `-1` during remote startup; consumers that require a positive PID immediately, including the ACP child backend, cannot use this provider unchanged.
 - **Private state lives for the sandbox lifetime** — process directories and valid spill files remain under `.dsh-e2b` until the owner deletes the sandbox; this POC supplies no in-sandbox sweep.
 - **Control state shares the sandbox user's UID** — E2B runs every command as the same default user, so `0700`/`0600` modes cannot isolate `.dsh-e2b` control files from concurrently running sandbox processes; real isolation needs an E2B per-command user or an out-of-band control channel.
 - **Numeric process identities are not reuse-fenced** — E2B exposes numeric PID/PGID input, signalling, and cleanup operations but no atomic identity-bound alternative; replacement is deferred until E2B adds an identity primitive or a failure demonstrates a narrower protocol.

+ 4 - 5
packages/e2b/subprocess-e2b/README.zh.md

@@ -29,7 +29,7 @@ kind: "package-reference"
 
 ### 何时选择
 
-当组合已经使用 E2B 沙箱且希望命令与终端在其中运行时,选择本包。宿主执行请选择本地子进程包。需要立即获得进程 ID 的工具——例如 ACP(Agent Client Protocol)子进程后端——无法使用本包。
+当组合已经使用 E2B 沙箱且希望命令与终端在其中运行时,选择本包。宿主执行请选择本地子进程包。
 
 ### 配置
 
@@ -71,7 +71,7 @@ agent 可以在沙箱中打开交互式终端、发送输入、读取输出,
 
 ### 设计理念
 
-- **延后的远程身份。** 同步 seam 从不阻塞在网络请求上:句柄异步发布真实进程组 ID,包装层的私有文件是 pid、退出码与 spill 有效性的权威来源
+- **提供方私有的远程身份。** 同步 seam 从不阻塞在网络请求上。包装层的私有文件会异步发布进程组身份,供 stdin、观察、终止与完全停稳检查使用,同时发布直接退出码与 spill 有效性;该身份不是请求目标的 PID
 - **单一终止阶梯。** 终止、回滚与资源释放共享同一条进程组信号路径——先 `SIGTERM`,再 `SIGKILL` 加 SDK kill 回退——并把已证明的完全停稳视为最终状态。
 - **环境必须显式。** 宿主内容与形似凭据的内容都不会隐式进入沙箱;每个环境值都会被清理,每个 `spec.env` 条目都是显式选择。
 
@@ -91,9 +91,9 @@ agent 可以在沙箱中打开交互式终端、发送输入、读取输出,
 
 引导脚本会从沙箱 PATH 解析自身所需的工具,拒绝任何缺失或不可执行的路径,通过 `env -i` 与 `setsid --wait` 执行 exec,把进程组 ID 与退出码发布到 `ctx.e2b.runtimeRoot/processes` 下的私有文件,并把 stdout 与 stderr 重定向到带保留完成帧的 base64 编码器;`tee` 与 `head -c` 约束可选 spill 文件的大小。
 
-### 进程身份与发布
+### 私有进程身份与发布
 
-同步 seam 会立即返回句柄,同时命令异步启动;`pid` 在包装层发布进程组 ID 且适配器验证通过之前保持 `-1`,stdin 与常规观察都等待该发布。启动信号会在分配前中止环境与私有状态准备;分配开始后,取消会等待可清理的临时 SDK 句柄。
+同步 seam 会立即返回句柄,同时命令异步启动。包装层会发布私有进程组 ID,供 stdin、观察、终止与完全停稳检查使用,但该 ID 不是请求目标的 PID。启动信号会在分配前中止环境与私有状态准备;分配开始后,取消会等待可清理的临时 SDK 句柄。
 
 ### 环境边界
 
@@ -142,7 +142,6 @@ agent 可以在沙箱中打开交互式终端、发送输入、读取输出,
 这些限制说明本提供方何时不合适,或何时需要特别的运维注意。它们是当前包约束,不是任务积压。
 
 - **SDK 仍会在宿主内存中保留完整命令输出**:即使本适配器公开的是有界原始字节尾部,E2B `CommandHandle.stdout` 与 `.stderr` 仍会累积 base64 传输内容,因此无法达到子进程 seam 通常提供的宿主内存边界,而且传输保留量大于源数据流。
-- **不支持需要同步 PID 的消费方**:远程启动期间 `pid` 保持 `-1`;包括 ACP 子进程后端在内,要求立即获得正 PID 的消费方无法原样使用本提供方。
 - **私有状态随沙箱生命周期存在**:进程目录与有效的 spill 文件会留在 `.dsh-e2b` 下,直到所有者删除沙箱;本 POC 不提供沙箱内清理。
 - **控制状态与沙箱用户同 UID**:E2B 以同一默认用户运行每条命令,因此 `0700`/`0600` 权限无法把 `.dsh-e2b` 控制文件与并发运行的沙箱进程隔离开;真正的隔离需要 E2B 提供按命令用户或带外控制通道。
 - **数值进程身份没有复用围栏**:E2B 公开基于数值 PID/PGID 的输入、信号发送与清理操作,却没有与身份原子绑定的替代方案;在 E2B 新增身份原语,或实际故障证明需要更窄的协议之前,替代方案继续延后。

+ 24 - 1
packages/e2b/subprocess-e2b/src/index.ts

@@ -6,6 +6,7 @@
 
 import { randomUUID } from 'node:crypto'
 import { posix } from 'node:path'
+import { inspect } from 'node:util'
 import { Context } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
 import { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess'
@@ -48,6 +49,13 @@ function requireRepresentableGrace(graceMs: number): void {
   }
 }
 
+function validateNoNullByte(subject: string, value: string): void {
+  if (!value.includes('\0')) return
+  const error = new TypeError(`${subject} must be a string without null bytes. Received ${inspect(value)}`)
+  Object.assign(error, { code: 'ERR_INVALID_ARG_VALUE' })
+  throw error
+}
+
 /** E2B command manager registered as `ctx.subprocess`. */
 export class E2BSubprocessRuntime extends SubprocessRuntime {
   static inject = ['e2b']
@@ -145,7 +153,22 @@ export class E2BSubprocessRuntime extends SubprocessRuntime {
     }
     requireRepresentableGrace(spec.graceMs)
     if (spec.signal?.aborted === true) {
-      throw new Error(`aborted before spawn: ${String(spec.signal.reason)}`)
+      let reason = 'aborted'
+      try {
+        reason = String(spec.signal.reason ?? reason)
+      } catch {
+        // Arbitrary caller-owned reasons cannot escape the stable Error boundary.
+      }
+      throw new Error(`aborted before spawn: ${reason}`)
+    }
+    spec.argv.forEach((value, index) => {
+      validateNoNullByte(index === 0 ? "The argument 'file'" : `The argument 'args[${String(index - 1)}]'`, value)
+    })
+    validateNoNullByte("The property 'options.cwd'", spec.cwd)
+    for (const [key, value] of Object.entries(spec.env ?? {})) {
+      if (value === undefined) continue
+      validateNoNullByte(`The property 'options.env['${key}']'`, key)
+      validateNoNullByte(`The property 'options.env['${key}']'`, value)
     }
     const stateDir = posix.join(this.ctx.e2b.runtimeRoot, 'processes', randomUUID())
     const handle = new E2BSubprocessHandle(this.ctx.e2b, spec, stateDir, this.pollMs)

+ 8 - 13
packages/e2b/subprocess-e2b/src/process.ts

@@ -155,7 +155,7 @@ function waitWithSignal<T>(promise: Promise<T>, signal: AbortSignal | undefined)
   })
 }
 
-/** E2B-backed subprocess handle with deferred remote PID acquisition. */
+/** E2B-backed subprocess handle with private remote process-group tracking. */
 export class E2BSubprocessHandle implements SubprocessHandle {
   readonly stdin: Writable | undefined
   readonly stdout: PassThrough | undefined
@@ -174,7 +174,7 @@ export class E2BSubprocessHandle implements SubprocessHandle {
   private readonly stderrReader: E2BOutputReader | undefined
   private readonly paths: RemotePaths
   private controlEnvs: Record<string, string> = {}
-  private remotePid = -1
+  private remoteProcessGroupId: number | undefined
   private outputTransportError: Error | undefined
   private outputDrainExpired = false
   private stateDirectoryCreated = false
@@ -225,11 +225,6 @@ export class E2BSubprocessHandle implements SubprocessHandle {
     if (spec.signal?.aborted === true) this.terminate()
   }
 
-  /** Remote process id after start; `-1` while E2B startup is pending or after it fails. */
-  get pid(): number {
-    return this.remotePid
-  }
-
   /** @inheritdoc */
   terminate(): void {
     if (this.quiescenceProven || this.terminationAttempt !== undefined) return
@@ -260,7 +255,7 @@ export class E2BSubprocessHandle implements SubprocessHandle {
         this.markQuiescent()
         return true
       }
-      if (this.remotePid <= 0) {
+      if (this.remoteProcessGroupId === undefined) {
         const attempt = this.terminationAttempt
         if (attempt !== undefined && await waitWithSignal(attempt.catch(() => undefined), signal) === WAIT_ABORTED) {
           return false
@@ -293,7 +288,7 @@ export class E2BSubprocessHandle implements SubprocessHandle {
       }
       throw error
     }
-    const processGroupId = this.remotePid > 0 ? this.remotePid : handle.pid
+    const processGroupId = this.remoteProcessGroupId ?? handle.pid
     while (await this.groupAlive(sandbox, processGroupId, signal)) {
       this.throwTerminationFailure()
       if (!await waitTick(this.pollMs, signal)) return false
@@ -349,7 +344,7 @@ export class E2BSubprocessHandle implements SubprocessHandle {
       }
       this.commandState.resolve(handle)
       try {
-        this.remotePid = await this.waitForProcessGroupId(sandbox, completion)
+        this.remoteProcessGroupId = await this.waitForProcessGroupId(sandbox, completion)
       } catch (error: unknown) {
         try {
           await this.rollbackUnpublishedGroup(sandbox, handle)
@@ -559,7 +554,7 @@ export class E2BSubprocessHandle implements SubprocessHandle {
   }
 
   private async rollbackPublishedFailure(error: unknown): Promise<unknown> {
-    if (this.remotePid <= 0 || this.quiescenceProven) return error
+    if (this.remoteProcessGroupId === undefined || this.quiescenceProven) return error
     this.terminate()
     try {
       await this.waitForExit()
@@ -599,13 +594,13 @@ export class E2BSubprocessHandle implements SubprocessHandle {
       this.markQuiescent()
       return
     }
-    if (!isValidProcessId(handle.pid) && this.remotePid <= 0) {
+    if (!isValidProcessId(handle.pid) && this.remoteProcessGroupId === undefined) {
       await handle.kill()
       this.markQuiescent()
       return
     }
     const sandbox = await this.runtime.getSandbox()
-    const processGroupId = this.remotePid > 0 ? this.remotePid : handle.pid
+    const processGroupId = this.remoteProcessGroupId ?? handle.pid
     await this.terminateGroup(sandbox, handle, processGroupId)
   }
 

+ 42 - 15
packages/e2b/subprocess-e2b/tests/subprocess.spec.ts

@@ -400,12 +400,10 @@ describe('E2BSubprocessHandle', () => {
         KEEP: undefined,
       },
     }), '/workspace/.dsh-e2b/processes/one')
-    expect(handle.pid).toBe(-1)
     handle.stdin!.write('hello')
     handle.stdin!.end()
     fake.releaseStart()
     await flush()
-    expect(handle.pid).toBe(4343)
     expect(fake.handle.sent.map(value => String(value))).toEqual(['hello'])
     expect(fake.handle.closes).toBe(1)
     const controlEnvs = fake.startOptions?.envs
@@ -1164,7 +1162,6 @@ describe('E2BSubprocessHandle', () => {
     fake.backgroundError = new Error('start failed')
     const handle = testHandle(runtime(fake), spec(), '/runtime/fail')
     await expect(handle.done).rejects.toThrow('start failed')
-    expect(handle.pid).toBe(-1)
     expect(fake.removed).toContain('/runtime/fail/environment')
     expect(fake.removed).toContain('/runtime/fail')
     await expect(handle.waitForExit()).resolves.toBe(true)
@@ -1405,6 +1402,17 @@ describe('E2BSubprocessHandle', () => {
     await expect(absent.waitForExit()).resolves.toBe(true)
   })
 
+  it('keeps polling while a running command has not published its process group yet', async () => {
+    const fake = new FakeSandbox()
+    fake.processGroupReads.push('', '4242\n')
+    const handle = testHandle(runtime(fake), spec(), '/runtime/delayed-group-publication', 1)
+
+    await vi.waitFor(() => { expect(fake.processGroupReads).toEqual([]) })
+    fake.finish()
+    await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
+    await expect(handle.waitForExit()).resolves.toBe(true)
+  })
+
   it('preserves publication failure and reports cleanup that cannot be verified', async () => {
     const fake = new FakeSandbox()
     fake.processGroupId = 'not-a-pid\n'
@@ -1447,15 +1455,6 @@ describe('E2BSubprocessHandle', () => {
     await expect(observed.waitForExit()).resolves.toBe(true)
   })
 
-  it('waits for delayed process-group publication', async () => {
-    const fake = new FakeSandbox()
-    fake.processGroupReads.push('', '4242\n')
-    const handle = testHandle(runtime(fake), spec(), '/runtime/delayed-group')
-    await vi.waitFor(() => { expect(handle.pid).toBe(4242) })
-    fake.finish()
-    await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
-  })
-
   it('handles output backpressure and contains a stderr sink failure', async () => {
     const fake = new FakeSandbox()
     const handle = testHandle(runtime(fake), spec({
@@ -1775,9 +1774,37 @@ describe('E2BSubprocessRuntime', () => {
     await expect(handle.done).rejects.toThrow('start failed during disposal')
   })
 
-  it('validates synchronous spawn preconditions', async () => {
-    const { ctx } = await service()
+  it('validates synchronous spawn preconditions before handle or remote work', async () => {
+    const fake = new FakeSandbox()
+    const getSandbox = vi.fn(async () => fake.sandbox)
+    const { ctx } = await service(fake, runtime(fake, getSandbox))
+    const live = (ctx.subprocess as unknown as { live: Set<E2BSubprocessHandle> }).live
     expect(() => ctx.subprocess.spawn(spec({ argv: [] }))).toThrow(/non-empty program/)
-    expect(() => ctx.subprocess.spawn(spec({ signal: AbortSignal.abort('stop') }))).toThrow(/aborted before spawn/)
+    expect(() => ctx.subprocess.spawn(spec({ signal: AbortSignal.abort('stop') })))
+      .toThrow(new Error('aborted before spawn: stop'))
+    expect(() => ctx.subprocess.spawn(spec({ signal: AbortSignal.abort(null) })))
+      .toThrow(new Error('aborted before spawn: aborted'))
+    const throwingReason = { toString: () => { throw new Error('caller reason escaped') } }
+    expect(() => ctx.subprocess.spawn(spec({ signal: AbortSignal.abort(throwingReason) })))
+      .toThrow(new Error('aborted before spawn: aborted'))
+
+    for (const invalid of [
+      spec({ argv: ['bash\0'] }),
+      spec({ argv: ['bash', 'bad\0arg'] }),
+      spec({ cwd: 'bad\0cwd' }),
+      spec({ env: { REMOVED: undefined, 'BAD\0KEY': 'value' } }),
+      spec({ env: { BAD: 'bad\0value' } }),
+    ]) {
+      let thrown: unknown
+      try {
+        ctx.subprocess.spawn(invalid)
+      } catch (error) {
+        thrown = error
+      }
+      expect(thrown).toMatchObject({ name: 'TypeError', code: 'ERR_INVALID_ARG_VALUE' })
+    }
+    expect(getSandbox).not.toHaveBeenCalled()
+    expect(live).toEqual(new Set())
+    expect(fake.directories).toEqual([])
   })
 })

+ 1 - 0
packages/extensions/cordis-client-runner/src/client/slot-catalog.ts

@@ -1227,6 +1227,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     slotInject: '',
     declaredBy: 'an entry in \'conversation.session.header\' (client-ui-conversation), so it exists while that entry is mounted',
     occupants: [
+      'client-ui-open-in-app OpenInAppAction id \'open-in-app\'',
       'session-log-export SessionLogDownloadHeaderAction id \'session-log-download\'',
     ],
     replaceRisk: 'none',

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

@@ -2354,7 +2354,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
   {
     key: 'subprocess',
     summary: 'Abstract subprocess service.',
-    description: 'Abstract subprocess service. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.subprocess` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Executable paths belong to one execution world shared with the mounted filesystem provider.\n- spawn returns immediately with a live handle; `done` resolves at process close with exit facts and rejects only for spawn-level failures.\n- Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another\'s output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.\n- SubprocessHandle.terminate (and the spec\'s abort signal) escalates SIGTERM→grace→SIGKILL — the only termination verb — tree-scoped on every platform. SubprocessHandle.waitForExit observes whole-tree liveness, so a consumer-owned teardown ladder can hold each tier on real quiescence.\n- Disposal of the service terminates all still-running managed processes and awaits their exit.\n- spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits.',
+    description: 'Abstract subprocess service. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.subprocess` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Executable paths belong to one execution world shared with the mounted filesystem provider.\n- spawn returns a live handle synchronously. Target identity remains provider-private; `done` resolves with the spawned command\'s exit facts and may reject for spawn or provider failures.\n- Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another\'s output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.\n- SubprocessHandle.terminate (and the spec\'s abort signal) starts the provider\'s documented procedure against its managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its signalling and observability limits.\n- Disposal of the service terminates all still-running managed processes and awaits their exit.\n- spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits.',
     methods: [
       {
         signature: 'abstract resolveExecutable( command: string, env?: Readonly<Record<string, string>>, signal?: AbortSignal, ): Promise<string>',
@@ -2367,10 +2367,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
         description: 'Start one managed child process from a fully-specified spec; this seam applies no defaults.',
         parameters: [{ name: 'spec', description: 'argv, directory, stdio dispositions, grace, cancellation, and environment.' }],
         returns: 'the live process handle (streams/readers, signalling, outcome promise).',
+        throws: ['synchronously when pre-aborted or when argv, cwd, environment, or grace is invalid before handle creation.'],
       },
       {
         signature: 'abstract spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise<SubprocessTerminalHandle>',
-        description: 'Allocate a real terminal and start one owned process session. This is the only non-pipe process primitive: implementations own terminal byte I/O, foreground groups, signals, and complete session-tree cleanup.',
+        description: 'Allocate a real terminal and start one owned process session. This is the only non-pipe process primitive: implementations own terminal byte I/O, foreground groups, signals, and whole-session quiescence.',
         parameters: [{ name: 'spec', description: 'fully specified argv, cwd, environment, dimensions, grace, and allocation cancellation.' }],
         returns: 'the live terminal handle after allocation succeeds.',
       },
@@ -5697,7 +5698,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'SubprocessHandle',
-    declaration: 'export interface SubprocessHandle {\n    readonly pid: number;\n    readonly stdin: Writable | undefined;\n    readonly stdout: Readable | undefined;\n    readonly stderr: Readable | undefined;\n    readonly collected: SubprocessCollectedOutputs;\n    readonly done: Promise<SubprocessOutcome>;\n    terminate(): void;\n    waitForExit(signal?: AbortSignal): Promise<boolean>;\n}',
+    declaration: 'export interface SubprocessHandle {\n    readonly stdin: Writable | undefined;\n    readonly stdout: Readable | undefined;\n    readonly stderr: Readable | undefined;\n    readonly collected: SubprocessCollectedOutputs;\n    readonly done: Promise<SubprocessOutcome>;\n    terminate(): void;\n    waitForExit(signal?: AbortSignal): Promise<boolean>;\n}',
   },
   {
     name: 'SubprocessOutcome',

+ 5 - 4
packages/fs/tool-fs-search/src/search-core.ts

@@ -201,9 +201,10 @@ export function resolveRgPath(): Promise<string> {
  * `SEARCH_INVALID_PATTERN`, the rest → `SEARCH_FAILED` /
  * `SEARCH_RAW_OUTPUT_OVERFLOW`). Both launch-time failure domains are
  * classified: a synchronous throw at spawn CREATION (a NUL in argv, an abort
- * racing the pre-check, a rejected `@vscode/ripgrep` resolution) and a
- * rejection of `handle.done` (the seam's infrastructure failures) both become
- * `SEARCH_FAILED` with the original as `cause` — an abort already observed by
+ * racing the pre-check, a rejected `@vscode/ripgrep` resolution) reports that
+ * the command could not start, while a rejection of `handle.done` reports a
+ * provider failure without claiming whether execution began. Both become
+ * `SEARCH_FAILED` with the original as `cause`; an abort already observed by
  * creation time becomes `SEARCH_ABORTED` instead.
  *
  * @param ctx - the plugin context; execution uses its `subprocess` service.
@@ -258,7 +259,7 @@ export async function runRipgrep(
   try {
     outcome = await handle.done
   } catch (error: unknown) {
-    throw new SearchError(`${toolName} could not start its search command (ripgrep launch failed)`, 'SEARCH_FAILED', { cause: error })
+    throw new SearchError(`${toolName} subprocess failed before reporting an outcome (ripgrep provider failure)`, 'SEARCH_FAILED', { cause: error })
   }
   const stdout = handle.collected.stdout?.readFrom(0)
   const stderr = handle.collected.stderr?.readFrom(0)

+ 3 - 2
packages/fs/tool-fs-search/tests/integration.spec.ts

@@ -189,12 +189,13 @@ describe('search tools over the real subprocess service + the packaged rg', () =
       expect(result.error).toMatchObject({ info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } })
     })
 
-    it('an unusable session cwd (spawn failure) is SEARCH_FAILED', async () => {
+    it('an unusable session cwd provider rejection is SEARCH_FAILED', async () => {
       const gone = join(dir, 'deleted-session-dir')
       const result = await call('glob', { pattern: '*' }, { session: { header: { id: 'session-int', cwd: gone } } })
       expect(result.isError).toBe(true)
       expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_FAILED' } })
-      expect(text(result)).toContain('could not start')
+      expect(text(result)).toContain('subprocess failed before reporting an outcome')
+      expect(text(result)).not.toContain('could not start')
     })
   })
 })

+ 5 - 6
packages/fs/tool-fs-search/tests/tools.spec.ts

@@ -96,7 +96,6 @@ class FakeReader implements SubprocessOutputReader {
  * abort→terminate escalation.
  */
 class FakeHandle implements SubprocessHandle {
-  readonly pid = 4242
   readonly stdin = undefined
   readonly stdout = undefined
   readonly stderr = undefined
@@ -472,10 +471,9 @@ describe('workdir derivation and signal forwarding', () => {
       .toMatchObject({ name: 'SearchError', code: 'SEARCH_ABORTED' })
   })
 
-  it('translates a spawn rejection into SEARCH_FAILED even when the signal aborts concurrently', async () => {
-    // The seam rejects only for infrastructure failures (unusable workdir,
-    // missing binary); the abort happened after dispatch, so the launch
-    // failure is the reportable cause with the original error chained.
+  it('translates a provider rejection without claiming the search command never started', async () => {
+    // The public seam does not expose whether a done rejection happened before
+    // or after target execution; the original provider error remains chained.
     const { ctx, subprocess } = await setup()
     const controller = new AbortController()
     subprocess.handler = () => {
@@ -487,7 +485,8 @@ describe('workdir derivation and signal forwarding', () => {
 
     expect(result.isError).toBe(true)
     expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_FAILED' } })
-    expect(text(result)).toContain('could not start')
+    expect(text(result)).toContain('subprocess failed before reporting an outcome')
+    expect(text(result)).not.toContain('could not start')
   })
 
   it('classifies a synchronous spawn-creation throw as SEARCH_FAILED', async () => {

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików