diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md
new file mode 100644
index 0000000000..805f27ba2e
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md
@@ -0,0 +1,38 @@
+# Agent Note: The subprocess seam goes Node-shaped and every eligible spawner rides it
+
+Status: implemented
+
+English | [中文](2026-07-26-subprocess-consumer-migration.zh.md)
+
+## Problem
+
+The [subprocess seam](2026-07-26-subprocess-seam.md) shipped shaped for exactly one consumer family: batch-collected stdout/stderr, batch stdin, a single escalating `kill()`. That was deliberate scope control, and its own note records "migrate the other spawn sites" as rejected-for-now. Review on the introducing PR reversed that deferral: the stacked follow-up should reshape the interface toward Node's API and move the remaining process-running places onto the service. The remaining spawners each carried a private copy of some slice of the same mechanics — lsp-local had its own detached-tree signalling (POSIX group + Windows taskkill + liveness polling), subagent-subprocess had the dispose ladder and its own scrub, mcp-client and pty-local and the SDK helper each had a third/fourth/fifth copy of the credential scrub — and none of it was swappable or centrally testable.
+
+## Decision
+
+The seam's vocabulary is now Node-shaped, and every spawner that can ride the service does:
+
+- **Per-stream stdio dispositions** on `SubprocessSpawnSpec`: `'pipe'` (the raw `Readable`/`Writable`, for consumer-owned protocol framing), `'inherit'` (diagnostics to the parent's stream), and collect mode `{ maxBytes, spill? }` — the original bounded tail-keep shape, with the spill file now optional so a diagnostic tail (a language server's stderr) buffers without touching disk. stdin is `'ignore'`, `'pipe'`, or `{ data }` (write-and-close batch).
+- **`SubprocessOutcome` carries exit facts only** (Node's close-event vocabulary); collected output stays readable through `handle.collected` after settlement (spill fds seal at the settle boundary), so batch and streaming callers share one access path and nothing is copied into the outcome.
+- **Tree-scoped termination, split Node-style**: `kill(signal?)` sends one signal and is a no-op after settlement; `terminate()` owns the SIGTERM→grace→SIGKILL escalation (and serves the spec's abort signal); `waitForExit()` polls tree liveness (POSIX group probe; direct-child boundary on Windows); `dispose(graces)` is the cooperative stdin-EOF→SIGTERM→SIGKILL ladder absorbed from `subagent-subprocess`, memoized per handle. Windows tree termination (`taskkill /T`, injectable) moved in from lsp-local, so tree semantics are platform-correct for every consumer.
+- **One scrub definition**: `scrubbedParentEnv()`/`SENSITIVE_ENV_PATTERN` live on the seam. Spawners that cannot route the spawn itself through the service — pty-local (node-pty owns the fork) and mcp-client (the MCP SDK owns the transport spawn) — import the function, so environment policy is single-sourced even where process ownership is not; the SDK helper's `scrubEnvironment()` defaults through it as well.
+
+Migrations landed with the reshape: **bash-local/bash-sandbox** (collect modes + batch stdin; the bash `kill()` maps to `terminate()` so `task_kill` keeps escalation semantics), **lsp-local** (piped protocol streams + a no-spill collected stderr tail; `LspConnection` takes the seam's spawn function; its private tree-op helpers deleted), **subagent-acp** (piped ndjson streams + inherited stderr; spawn failure surfaces through `done` rejection into the same startup race; disposal is `handle.dispose` with the plugin's configured graces). **`dsh-subagent-subprocess` is deleted** — the dispose ladder and scrub are the seam's; the unused isolated-config-dir helper died with it (no consumer existed).
+
+Compositions mounting lsp-local or subagent-acp now load `dsh-subprocess-local` (the plugins inject `'subprocess'`); the acp/lsp test fixtures gained the row.
+
+## Alternatives considered
+
+**Keep the batch-only seam and let stream consumers stay bespoke.** The introducing note's position, rejected by review: it leaves three private copies of tree signalling and five of the scrub, and any future runner (containerized executor, remote process host) would have to pick which private copy to fork. The Node-shaped dispositions cover all three observed stream shapes without widening the outcome type or buffering piped streams.
+
+**A single `stdio: 'pipe' | 'inherit' | 'collect'` mode for all three streams at once.** Rejected: real consumers mix modes per stream (lsp: pipe/pipe/collect; acp: pipe/pipe/inherit; bash: data/collect/collect). Per-stream dispositions are exactly Node's shape and avoid a second spawn call for the mixed cases.
+
+**Migrate pty-local and mcp-client spawns too.** Rejected on ownership grounds, not scope: node-pty's `fork()` allocates the terminal itself, and the MCP SDK's `StdioClientTransport` spawns internally — neither call site is ours to route. They adopt the shared scrub (the part that is policy), and their READMEs say why the spawn stays put.
+
+**Migrate the test-support launchers (acp-snapshot, loader-smoke) and the SDK package-manager runner.** Rejected: the support packages are deliberately dependency-light test infrastructure that must not depend on product seams, and the SDK wizard's `stdio: 'inherit'`-with-redirect semantics plus its out-of-composition lifecycle (no cordis context at all) make the service a poor fit; it shares the scrub instead.
+
+## Consequences
+
+Bought: one implementation of tree signalling, escalation, the dispose ladder, bounded collection, and the scrub, tested once in `dsh-subprocess-local`'s suites (including injected-platform Windows coverage that lsp-local's private copy never had); lsp-local and subagent-acp shed their process plumbing and their children now survive plugin reloads and die with composition teardown like bash's; a whole package (`dsh-subagent-subprocess`) is gone. The seam README's "one consumer family" limitation is retired.
+
+Cost: the seam is wider — three stdio modes and four termination verbs instead of one of each — so a future backend implements more surface; the compositions for lsp-local/subagent-acp each carry the subprocess row now; and `SubprocessOutcome` no longer carries output, a breaking shape change inside the still-unreleased stack (the PR2 layer was updated in place rather than shimmed, per the pre-release stance). pty-local/mcp-client/SDK/test-support spawns remain outside the service by ownership, with the scrub as the shared floor.
diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md
index abfc43c1a9..5cf0e59660 100644
--- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md
+++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md
@@ -12,7 +12,7 @@ English | [中文](2026-07-26-subprocess-seam.zh.md)
A new `subprocess/` capability family owns "run and manage a process"; the bash family keeps "run a bash command" and consumes it:
-- **`@deepseek-ai/dsh-subprocess` (interface)** — the abstract `SubprocessService` owning `ctx.subprocess` with one method, `spawn(spec): SubprocessHandle`, and the shared vocabulary: the fully-explicit `SubprocessSpawnSpec` (argv, cwd, per-stream caps, spill cap, grace — no defaults; deployment-varying knobs stay with the calling seam's config, per the `dsh-bash` request/spec template and the no-hidden-defaults rule), `SubprocessHandle` with non-consuming offset-based readers, `SubprocessOutcome` with deliberately no timeout/cancel classification, and the `DSH_ENV_PREFIX`/`DshEnvironment`/`CollectedOutput` types. `argv` is never shell-interpreted.
+- **`@deepseek-ai/dsh-subprocess` (interface)** — the abstract `SubprocessService` owning `ctx.subprocess` with one method, `spawn(spec): SubprocessHandle`, and the shared vocabulary: the fully-explicit `SubprocessSpawnSpec` (argv, cwd, per-stream stdio dispositions, grace — no defaults; deployment-varying knobs stay with the calling seam's config, per the `dsh-bash` request/spec template and the no-hidden-defaults rule), `SubprocessHandle` with non-consuming offset-based readers, `SubprocessOutcome` with deliberately no timeout/cancel classification, and the shared scrub plus `DSH_ENV_PREFIX`/`DshEnvironment`/`CollectedOutput` types. `argv` is never shell-interpreted. (The [consumer-migration Agent Note](2026-07-26-subprocess-consumer-migration.md) later widened the stdio and termination vocabulary Node-ward.)
- **`@deepseek-ai/dsh-subprocess-local` (implementation)** — `LocalSubprocessService` over the former `run.ts` plumbing (`spawn.ts`): detached groups, tail-keep truncation with private bounded spill files, credential scrub with the two-channel `DSH_*` merge, group kill escalation, and disposal that kills and joins every still-running managed process. It has no config; every limit arrives on the spec. The terminal `ENV_OVERRIDES` (`TERM=dumb` etc.) did NOT move — that is bash-tool presentation policy and stays in `dsh-bash-local`, merged through the ordinary env channel.
- **`dsh-bash-local` (consumer)** — `inject: ['subprocess']`; maps each resolved `BashExecSpec` onto a `SubprocessSpawnSpec` (`['bash', '-c', command]`), keeps its config, `resolve()` defaulting, fused-deadline `timedOut`/`aborted` classification, the `[stderr]`-marked background read merge with its consuming cursor, and the `onProcessDone` subclass hook. `dsh-bash-sandbox` is unchanged apart from redeclaring the inherited inject; it still wraps at the command-string level and re-enters the inherited spawn path.
- **`dsh-bash` (seam)** — re-exports the moved vocabulary from `dsh-subprocess`, so no bash consumer changes an import; `BashExecRequest`/`BashExecSpec`/`BashProcess` and the sandbox facts remain bash-owned.
@@ -25,7 +25,7 @@ Background-process lifetime moved from the executor to the subprocess service: t
**Leave the process plumbing inside `dsh-bash-local` (status quo).** Rejected for the same reason the [task registry split](2026-07-26-task-registry-seam.md) landed: the boundary is stable and already documented in-code (`run.ts`'s module doc said "this layer reacts to an abort signal; the executor owns deadlines and classifies causes"), and keeping it private makes every future non-shell runner either fork the mechanics or depend on a bash-named package for non-bash work. The user-visible driver for this stack was exactly this split.
-**Migrate the repo's other spawn sites (lsp-local, pty-local, subagent-subprocess, sdk package-manager, test-support launchers) onto `ctx.subprocess` in the same change.** Rejected as scope creep with real design risk: those sites have materially different stream and lifecycle needs — node-pty ownership (pty), LSP framing over long-lived stdio with tree-kill fallbacks (lsp), stdin-EOF-first disposal ladders and no output buffering (subagent transports) — and forcing them under a handle shaped for bounded batch output would either bloat the seam or misfit the consumers. The seam ships proven against its one real consumer family, per the shape-interfaces-around-current-consumers rule; the others are named as deferred work in the seam README.
+**Migrate the repo's other spawn sites (lsp-local, pty-local, subagent-subprocess, sdk package-manager, test-support launchers) onto `ctx.subprocess` in the same change.** Rejected as scope creep with real design risk at this PR's scale: those sites have materially different stream and lifecycle needs — node-pty ownership (pty), LSP framing over long-lived stdio with tree-kill fallbacks (lsp), stdin-EOF-first disposal ladders and no output buffering (subagent transports) — and forcing them under a handle shaped for bounded batch output would either bloat the seam or misfit the consumers. The seam shipped proven against its one real consumer family, per the shape-interfaces-around-current-consumers rule. Review then asked for exactly that follow-up as a stacked PR; the [consumer-migration Agent Note](2026-07-26-subprocess-consumer-migration.md) records the Node-ward reshape and which sites moved (and which stayed, by ownership).
**Put `run_in_background`/task semantics into the process seam instead.** Rejected: that boundary already exists — `ctx.tasks` owns ids, ownership, and notices, and the bash tool adapts a `BashProcess` into task hooks. The process seam sits *below* the bash executor, not beside the task registry.
diff --git a/docs/capability-seams.md b/docs/capability-seams.md
index 5dc0a41a75..f1e473767d 100644
--- a/docs/capability-seams.md
+++ b/docs/capability-seams.md
@@ -87,6 +87,8 @@ flowchart LR
pkg_subprocess_local["subprocess-local"]
pkg_bash_local["bash-local"]
pkg_bash_sandbox["bash-sandbox"]
+ pkg_lsp_local["lsp-local"]
+ pkg_subagent_acp["subagent-acp"]
pkg_bash["bash"]
svc_bash["ctx.bash
Bash executor seam"]
svc_bashEnv["ctx.bashEnv
Managed bash environment registry"]
@@ -116,7 +118,6 @@ flowchart LR
svc_subagents["ctx.subagents
Subagent provider registry"]
pkg_subagent_spawn["subagent-spawn"]
pkg_subagent_fork["subagent-fork"]
- pkg_subagent_acp["subagent-acp"]
pkg_tool_ralph["tool-ralph"]
pkg_tasks["tasks"]
svc_tasks["ctx.tasks
Background task registry"]
@@ -270,6 +271,8 @@ flowchart LR
svc_subagents --> pkg_tool_subagent
svc_subprocess --> pkg_bash_local
svc_subprocess --> pkg_bash_sandbox
+ svc_subprocess --> pkg_lsp_local
+ svc_subprocess --> pkg_subagent_acp
svc_systemPrompt --> pkg_agent_loop
svc_systemPrompt --> pkg_tool_fs
svc_systemPrompt --> pkg_tool_pty
@@ -324,7 +327,7 @@ flowchart LR
| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tui-demo`](../packages/examples/tui-demo) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. |
| `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. |
| `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. |
-| `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | - | The bash executors spawn their process groups through ctx.subprocess; the service owns group lifetime, bounded spill-backed output, and kill escalation. |
+| `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox), [`lsp-local`](../packages/lsp/lsp-local), [`subagent-acp`](../packages/subagent/subagent-acp) | - | The bash executors, the LSP host, and the ACP subagent backend spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation. |
| `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them. |
| `ctx.bashEnv` | `core` | [`tool-bash`](../packages/bash/tool-bash) | - | - | - | Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace. |
| `ctx.pty` | `seam` | [`pty`](../packages/pty/pty) | [`pty-local`](../packages/pty/pty-local) | [`tool-pty`](../packages/pty/tool-pty) | - | The registry owns exact-Agent session identity and cleanup; backends own terminal mechanics, while tool-pty exposes the owner-scoped model surface. |
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index a21b11378d..4f90714ebf 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -716,7 +716,7 @@ Source: [`packages/llm/llm-retry/src/index.ts:39`](../packages/llm/llm-retry/src
## `@deepseek-ai/dsh-lsp-local`
-Requires: `lsp`
+Requires: `lsp` · `subprocess`
```ts config-catalog
/** Plugin configuration: provider id → local language-server configuration. */
@@ -752,7 +752,7 @@ export interface LspLocalServerConfig {
}
```
-Source: [`packages/lsp/lsp-local/src/index.ts:85`](../packages/lsp/lsp-local/src/index.ts)
+Source: [`packages/lsp/lsp-local/src/index.ts:87`](../packages/lsp/lsp-local/src/index.ts)
## `@deepseek-ai/dsh-mcp-client`
@@ -1286,7 +1286,7 @@ Source: [`packages/storage/storage-sqlite/src/index.ts:24`](../packages/storage/
## `@deepseek-ai/dsh-subagent-acp`
-Requires: `subagents`
+Requires: `subagents` · `subprocess`
```ts config-catalog
/** Config: how to spawn and drive the child ACP agent process. */
@@ -2108,6 +2108,5 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
- `@deepseek-ai/dsh-scripts` ([`packages/sdk/scripts/src/index.ts`](../packages/sdk/scripts/src/index.ts))
- `@deepseek-ai/dsh-session-title-llm` ([`packages/session-title/session-title-llm/src/index.ts`](../packages/session-title/session-title-llm/src/index.ts))
- `@deepseek-ai/dsh-subagent-inprocess` ([`packages/subagent/subagent-inprocess/src/index.ts`](../packages/subagent/subagent-inprocess/src/index.ts))
-- `@deepseek-ai/dsh-subagent-subprocess` ([`packages/subagent/subagent-subprocess/src/index.ts`](../packages/subagent/subagent-subprocess/src/index.ts))
- `@deepseek-ai/dsh-telemetry` ([`packages/sdk/telemetry/src/index.ts`](../packages/sdk/telemetry/src/index.ts))
- `@deepseek-ai/dsh-timeout` ([`packages/util/timeout/src/index.ts`](../packages/util/timeout/src/index.ts))
diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md
index 4108332e43..0f7f83a331 100644
--- a/docs/cordis-catalog/services.md
+++ b/docs/cordis-catalog/services.md
@@ -1565,24 +1565,24 @@ Abstract subprocess service. Subclass, implement spawn, and load the subclass as
Implementations must honor these semantics:
-- spawn returns immediately with a live handle; `done` resolves at process close and rejects only for spawn-level failures.
-- Output 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.
-- SubprocessHandle.kill and the spec's abort signal escalate SIGTERM→grace→SIGKILL across the whole process group.
-- Disposal kills all still-running managed processes and awaits their exit.
+- spawn returns immediately with a live handle; `done` resolves at process close with exit facts and rejects only for spawn-level 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.kill signals without escalation, SubprocessHandle.terminate (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL, and SubprocessHandle.dispose runs the cooperative EOF-first ladder — all tree-scoped on every platform.
+- Disposal of the service terminates all still-running managed processes and awaits their exit.
```ts cordis-catalog
/**
* Start one managed child process from a fully-specified spec; this seam
* applies no defaults.
- * @param spec - argv, directory, limits, grace, cancellation, and environment.
- * @returns the live process handle (readers, kill, outcome promise).
+ * @param spec - argv, directory, stdio dispositions, grace, cancellation, and environment.
+ * @returns the live process handle (streams/readers, signalling, outcome promise).
*/
abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle
```
Types: [SubprocessHandle](../core-data-structures/subprocess.md) · [SubprocessSpawnSpec](../core-data-structures/subprocess.md)
-Source: [`packages/subprocess/subprocess/src/index.ts:48`](../../packages/subprocess/subprocess/src/index.ts)
+Source: [`packages/subprocess/subprocess/src/index.ts:90`](../../packages/subprocess/subprocess/src/index.ts)
## `ctx.systemPrompt` — `SystemPrompt`
diff --git a/docs/core-data-structures/subprocess.md b/docs/core-data-structures/subprocess.md
index 6e7cea3990..ae0b291b88 100644
--- a/docs/core-data-structures/subprocess.md
+++ b/docs/core-data-structures/subprocess.md
@@ -1,12 +1,12 @@
# Subprocess
-The subprocess seam is split across interface ([dsh-subprocess](../../packages/subprocess/subprocess), `ctx.subprocess`) and implementation ([dsh-subprocess-local](../../packages/subprocess/subprocess-local)); its consumers are other capability seams — today the [bash executor family](bash.md), which passes `['bash', '-c', command]` argv and owns every default. This seam owns the managed `DSH_*` environment namespace and the `CollectedOutput` shape; [dsh-bash](../../packages/bash/bash) re-exports them so bash consumers keep one import root.
+The subprocess seam is split across interface ([dsh-subprocess](../../packages/subprocess/subprocess), `ctx.subprocess`) and implementation ([dsh-subprocess-local](../../packages/subprocess/subprocess-local)); its consumers are other capability seams and out-of-process backends — the [bash executor family](bash.md) (collect-mode batch output), the LSP host (piped protocol streams + a collected stderr tail), and the ACP subagent backend (piped protocol streams + inherited stderr). This seam owns the managed `DSH_*` environment namespace, the shared credential scrub (`scrubbedParentEnv`), and the `CollectedOutput` shape; [dsh-bash](../../packages/bash/bash) re-exports the vocabulary so bash consumers keep one import root.
Source: [`packages/subprocess/subprocess/src/types.ts`](../../packages/subprocess/subprocess/src/types.ts)
## Managed environment namespace and captured output
-`DSH_*` variables are Harness-owned child-process facts; implementations discard ambient `DSH_*` names before merging the caller's snapshot, and each captured stream reports its truncation and spill-recovery state through `CollectedOutput`.
+`DSH_*` variables are Harness-owned child-process facts; implementations discard ambient `DSH_*` names before merging the caller's snapshot, and each collected stream reports its truncation and spill-recovery state through `CollectedOutput`.
```ts type-equiv
/** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */
@@ -30,83 +30,170 @@ interface CollectedOutput {
}
```
-## The fully-explicit spawn spec
+## Node-shaped stdio dispositions
-The seam applies no defaults: every limit and directory is explicit on the spec, so the caller's own config — not a hidden subprocess-service default — decides them. `argv` is never shell-interpreted.
+Each stream's disposition is explicit, chosen per consumer: raw pipes for protocol framing (LSP JSON-RPC, ACP ndjson), inherit for pass-through diagnostics, and collect mode for bounded batch output — with the spill file optional, so a diagnostic tail (a language server's stderr) buffers without leaving files behind.
```ts type-equiv
/**
- * A fully-specified spawn request. This seam applies no defaults: every limit
- * and directory is explicit, so the caller's own config — not a hidden
- * subprocess-service default — decides them (the `dsh-bash` request/spec split
- * is the owning template).
+ * stdin disposition. `'ignore'` leaves fd 0 on `/dev/null`; `'pipe'` exposes
+ * {@link SubprocessHandle.stdin} for the caller's ongoing protocol writes;
+ * `{ data }` writes the bytes and closes (the batch shape).
+ */
+type SubprocessStdinMode = 'ignore' | 'pipe' | { readonly data: string }
+```
+
+```ts type-equiv
+/**
+ * Bounded in-memory collection for one output stream, with an optional
+ * full-stream spill file. Omitting `spill` keeps only the in-memory tail —
+ * the diagnostic-tail shape (a language server's stderr); including it makes
+ * the complete stream recoverable up to its cap (the bash tool shape).
+ */
+interface SubprocessCollect {
+ /** In-memory cap in bytes; overflow keeps the TAIL. */
+ maxBytes: number
+ /** Full-stream spill file; absent disables spilling entirely. */
+ spill?: {
+ /** Whole-stream byte cap; a larger stream discards its now-incomplete spill. */
+ maxBytes: number
+ }
+}
+```
+
+```ts type-equiv
+/**
+ * stdout/stderr disposition. `'pipe'` exposes the raw `Readable` for the
+ * caller's protocol decoding; `'inherit'` passes the parent's descriptor
+ * through (child diagnostics land on the harness's own stream); a
+ * {@link SubprocessCollect} object buffers boundedly with offset-based reads.
+ */
+type SubprocessOutputMode = 'pipe' | 'inherit' | SubprocessCollect
+```
+
+```ts type-equiv
+/** Per-stream stdio dispositions, all explicit — this seam applies no defaults. */
+interface SubprocessStdio {
+ stdin: SubprocessStdinMode
+ stdout: SubprocessOutputMode
+ stderr: SubprocessOutputMode
+}
+```
+
+## The fully-explicit spawn spec
+
+The seam applies no defaults: every disposition, limit, and directory is explicit on the spec, so the caller's own config — not a hidden subprocess-service default — decides them. `argv` is never shell-interpreted.
+
+```ts type-equiv
+/**
+ * A fully-specified spawn request. This seam applies no defaults: every
+ * disposition, limit, and directory is explicit, so the caller's own config —
+ * not a hidden subprocess-service default — decides them (the `dsh-bash`
+ * request/spec split is the owning template).
*/
interface SubprocessSpawnSpec {
/** Executable and arguments; `argv[0]` is the program. Never shell-interpreted here. */
argv: readonly string[]
/** Working directory for the child. */
cwd: string
- /** Stdout in-memory cap; overflow spills to disk (tail kept in memory). */
- stdoutMaxBytes: number
- /** Stderr in-memory cap; overflow spills to disk (tail kept in memory). */
- stderrMaxBytes: number
- /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */
- maxSpillBytes: number
- /** Grace period for kill escalation and for inherited pipes after process exit. */
+ /** Per-stream stdio dispositions. */
+ stdio: SubprocessStdio
+ /**
+ * Grace period in milliseconds 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).
+ */
graceMs: number
/**
- * Abort signal — kills the process group when it fires. The caller owns
- * deadlines and cause classification; this seam only reacts to the abort.
+ * Abort signal — starts the terminate escalation on the process tree when
+ * it fires. The caller owns deadlines and cause classification; this seam
+ * only reacts to the abort.
*/
signal?: AbortSignal | undefined
/**
- * Bytes to write to the child's stdin, then close it. Absent (or empty)
- * leaves stdin closed/empty.
- */
- stdin?: string | undefined
- /**
- * Ordinary environment entries merged after the implementation's credential
- * scrub. `DSH_*` names are rejected and belong in {@link dshEnv}.
+ * Ordinary environment entries merged onto the implementation's scrubbed
+ * parent base (see `scrubbedParentEnv`). `DSH_*` names are rejected and
+ * belong in {@link dshEnv}; a deliberately forwarded credential-shaped
+ * entry survives because this layer merges after the scrub.
*/
env?: Record | undefined
/**
- * Harness-owned `DSH_*` variables for this execution. Implementations
- * discard ambient `DSH_*` entries before merging this snapshot, so an
- * unavailable current fact cannot inherit a stale value from the harness
- * process, and reject non-`DSH_*` names supplied through this channel.
+ * Harness-owned `DSH_*` variables for this execution. The scrubbed base has
+ * already discarded ambient `DSH_*` entries, so an unavailable current fact
+ * cannot inherit a stale value from the harness process; non-`DSH_*` names
+ * on this channel are rejected.
*/
dshEnv?: DshEnvironment | undefined
}
```
-## Handles and offset-based reads
+## Handles: streams, readers, and tree-scoped termination
-A spawn returns a live handle immediately. Output readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; the consuming-cursor model the bash tool presents is consumer-owned state over these readers.
+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: `kill(signal)` sends one signal Node-style, `terminate()` escalates SIGTERM→grace→SIGKILL, `waitForExit()` observes the whole tree, and `dispose(graces)` runs the cooperative stdin-EOF→SIGTERM→SIGKILL ladder out-of-process children need.
```ts type-equiv
/**
- * A live child process. `kill()` starts the group SIGTERM→grace→SIGKILL
- * escalation; buffered output remains readable after exit.
+ * A live child process rooted in its own process tree. 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.
*/
interface SubprocessHandle {
- /** Process id (group leader); -1 when the spawn itself failed. */
+ /** Process id (tree root); -1 when the spawn itself failed. */
readonly pid: number
- /** Live stdout reader (also readable after exit). */
- readonly stdout: SubprocessOutputReader
- /** Live stderr reader (also readable after exit). */
- readonly stderr: SubprocessOutputReader
- /** Resolves when the process closes; rejects only for spawn-level failures. */
+ /** 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'`. */
+ readonly stdout: Readable | undefined
+ /** The child's raw stderr, present iff spawned with `stderr: 'pipe'`. */
+ 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. */
readonly done: Promise
- /** Begin SIGTERM→grace→SIGKILL on the process group. Idempotent. */
- kill(): void
+ /**
+ * Send one signal to the process tree, Node-style — no escalation, no
+ * timers. A no-op after the outcome has settled (the pid may be reused).
+ * @param signal - the signal to deliver (default `SIGTERM`; Windows
+ * force-terminates the tree for any value).
+ */
+ kill(signal?: NodeJS.Signals): void
+ /**
+ * Begin the SIGTERM → `graceMs` → SIGKILL escalation on the process tree
+ * (Windows force-terminates immediately). Idempotent; 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.
+ * @param signal - optional bound for the wait.
+ * @returns `true` when the tree exited, `false` when the signal aborted first.
+ */
+ waitForExit(signal?: AbortSignal): Promise
+ /**
+ * Tear the child down to quiescence, resolving only after exit: close stdin
+ * (when this handle owns a piped one) and allow cooperative flush for
+ * `eofGraceMs`, then SIGTERM with a `graceMs` window (POSIX), then forced
+ * tree termination with a final bounded `graceMs` wait.
+ * @param graces - the ladder's two windows, from the consumer's Config.
+ * @throws when the child still has not exited `graceMs` after the forced tier.
+ */
+ dispose(graces: SubprocessDisposeGraces): Promise
}
```
```ts type-equiv
/**
- * Cursor-free incremental access to one live output stream. Offsets are
+ * Cursor-free incremental access to one collected output stream. Offsets are
* whole-stream byte coordinates owned by the caller, so independent readers
- * cannot consume one another's output.
+ * cannot consume one another's output; `readFrom(0)` after settlement is the
+ * batch result (`lossy` then means the in-memory tail lost its head — the
+ * {@link CollectedOutput.truncated} fact).
*/
interface SubprocessOutputReader {
/**
@@ -134,26 +221,62 @@ interface SubprocessOutputRead {
}
```
-## Outcomes carry no cause classification
-
-`done` reports raw exit facts. The service kills on abort but never decides why — the caller reads the deadline signal it owns to classify timeout versus cancellation (the bash executor's `timedOut`/`aborted` split).
+```ts type-equiv
+/** Offset-based readers for the streams spawned in collect mode. */
+interface SubprocessCollectedOutputs {
+ /** Present iff stdout is a {@link SubprocessCollect}. */
+ readonly stdout?: SubprocessOutputReader
+ /** Present iff stderr is a {@link SubprocessCollect}. */
+ readonly stderr?: SubprocessOutputReader
+}
+```
```ts type-equiv
/**
- * Raw outcome of one closed process. Deliberately carries NO timeout or
- * cancellation classification: the service kills on abort but does not decide
- * why — the caller reads the signal it owns to classify causes.
+ * The two grace periods of the cooperative dispose ladder
+ * ({@link SubprocessHandle.dispose}). Consumers carry them as defaulted,
+ * validated Config fields, so teardown timing is deployment-tunable and this
+ * seam hardcodes nothing.
+ */
+interface SubprocessDisposeGraces {
+ /**
+ * Tier-1 window (ms): after stdin EOF, how long the child gets to quiesce
+ * ON ITS OWN — flush durable state, tear down its own descendants — before
+ * escalation to platform termination. Usually WIDER than
+ * {@link SubprocessDisposeGraces.graceMs}: a cooperative child's EOF-driven
+ * teardown may itself wait on a signal-trapping grandchild plus a final
+ * flush.
+ */
+ eofGraceMs: number
+ /**
+ * Termination confirmation window (ms): POSIX applies it after `SIGTERM`
+ * and again after `SIGKILL`; Windows applies it after the forced tree
+ * termination.
+ */
+ graceMs: number
+}
+```
+
+## Outcomes carry exit facts only
+
+`done` reports Node's close-event vocabulary and no cause classification — the service kills on abort but never decides why (the caller reads the deadline signal it owns, e.g. the bash executor's `timedOut`/`aborted` split). Collected output stays readable through `handle.collected` after settlement, so batch and streaming callers share one access path.
+
+```ts type-equiv
+/**
+ * Exit facts of one closed process — Node's `close`-event vocabulary.
+ * Deliberately carries NO timeout or cancellation classification (the caller
+ * reads the signal it owns to classify causes) and NO output: collected
+ * streams stay readable through {@link SubprocessHandle.collected} after
+ * settlement, so batch and streaming callers share one access path.
*/
interface SubprocessOutcome {
/** Exit code; null when the process died from a signal. */
exitCode: number | null
/** Terminating signal (e.g. 'SIGTERM'); null on normal exit. */
signal: NodeJS.Signals | null
- stdout: CollectedOutput
- stderr: CollectedOutput
}
```
## Service behavior
-The abstract [`SubprocessService`](../../packages/subprocess/subprocess/src/index.ts) seam defines `spawn` only; [`LocalSubprocessService`](../../packages/subprocess/subprocess-local/src/index.ts) is the local implementation (detached groups, tail-keep spill-backed collection, credential scrub, kill-and-join disposal). See [`dsh-subprocess`](../../packages/subprocess/subprocess/README.md) for the seam contract and [`dsh-subprocess-local`](../../packages/subprocess/subprocess-local/README.md) for the mechanics.
+The abstract [`SubprocessService`](../../packages/subprocess/subprocess/src/index.ts) seam defines `spawn` only; [`LocalSubprocessService`](../../packages/subprocess/subprocess-local/src/index.ts) is the local implementation (detached trees, per-disposition wiring, credential scrub, terminate-and-join disposal). See [`dsh-subprocess`](../../packages/subprocess/subprocess/README.md) for the seam contract and [`dsh-subprocess-local`](../../packages/subprocess/subprocess-local/README.md) for the mechanics.
diff --git a/docs/module-graph.md b/docs/module-graph.md
index f310cac386..435411beb0 100644
--- a/docs/module-graph.md
+++ b/docs/module-graph.md
@@ -64,7 +64,6 @@ flowchart TD
pkg_subagent_fork["subagent-fork"]
pkg_subagent_inprocess["subagent-inprocess"]
pkg_subagent_spawn["subagent-spawn"]
- pkg_subagent_subprocess["subagent-subprocess"]
pkg_tool_subagent["tool-subagent"]
end
subgraph group_web["packages/web"]
@@ -229,7 +228,6 @@ flowchart TD
pkg_timeout --> pkg_invariants
pkg_scope --> pkg_invariants
pkg_skill --> pkg_invariants
- pkg_subagent_subprocess --> pkg_invariants
pkg_acp_snapshot --> pkg_invariants
pkg_llm_mock_server --> pkg_invariants
pkg_loader_smoke --> pkg_invariants
@@ -273,6 +271,7 @@ flowchart TD
pkg_client_ui_workspace --> pkg_invariants
pkg_helper --> pkg_brand
pkg_helper --> pkg_invariants
+ pkg_helper --> pkg_subprocess
pkg_telemetry --> pkg_brand
pkg_telemetry --> pkg_invariants
pkg_telemetry --> pkg_paths
@@ -359,6 +358,7 @@ flowchart TD
pkg_lsp_local --> pkg_invariants
pkg_lsp_local --> pkg_llm
pkg_lsp_local --> pkg_lsp
+ pkg_lsp_local --> pkg_subprocess
pkg_lsp_local --> pkg_timeout
pkg_sandbox_local --> pkg_invariants
pkg_sandbox_local --> pkg_llm
@@ -517,6 +517,7 @@ flowchart TD
pkg_pty_local --> pkg_sandbox
pkg_pty_local --> pkg_sandbox_policy
pkg_pty_local --> pkg_session
+ pkg_pty_local --> pkg_subprocess
pkg_tasks_local --> pkg_agent
pkg_tasks_local --> pkg_invariants
pkg_tasks_local --> pkg_tasks
@@ -654,6 +655,7 @@ flowchart TD
pkg_tool_lsp --> pkg_tools
pkg_mcp_client --> pkg_invariants
pkg_mcp_client --> pkg_llm
+ pkg_mcp_client --> pkg_subprocess
pkg_mcp_client --> pkg_tools
pkg_tool_pty --> pkg_agent
pkg_tool_pty --> pkg_invariants
@@ -680,7 +682,7 @@ flowchart TD
pkg_subagent_acp --> pkg_llm
pkg_subagent_acp --> pkg_session
pkg_subagent_acp --> pkg_subagent
- pkg_subagent_acp --> pkg_subagent_subprocess
+ pkg_subagent_acp --> pkg_subprocess
pkg_subagent_inprocess --> pkg_agent
pkg_subagent_inprocess --> pkg_invariants
pkg_subagent_inprocess --> pkg_llm
@@ -819,7 +821,6 @@ flowchart TD
| [`timeout`](../packages/util/timeout) | `util` | [`invariants`](../packages/support/invariants) |
| [`scope`](../packages/core/scope) | `core` | [`invariants`](../packages/support/invariants) |
| [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/support/invariants) |
-| [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | `subagent` | [`invariants`](../packages/support/invariants) |
| [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | [`invariants`](../packages/support/invariants) |
| [`llm-mock-server`](../packages/support/llm-mock-server) | `support` | [`invariants`](../packages/support/invariants) |
| [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`invariants`](../packages/support/invariants) |
@@ -846,7 +847,7 @@ flowchart TD
| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
-| [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) |
+| [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) |
| [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) |
| [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) |
| [`storage-json`](../packages/storage/storage-json) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) |
@@ -875,7 +876,7 @@ flowchart TD
| [`llm-replay`](../packages/support/llm-replay) | `support` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`app-boot`](../packages/ui/app-boot) | `ui` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) |
| [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
-| [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`timeout`](../packages/util/timeout) |
+| [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
| [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |
| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
@@ -911,7 +912,7 @@ flowchart TD
| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) |
| [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) |
| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) |
-| [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session) |
+| [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) |
| [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) |
| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
@@ -934,11 +935,11 @@ flowchart TD
| [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
| [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) |
| [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
-| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`tools`](../packages/core/tools) |
+| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) |
| [`tool-pty`](../packages/pty/tool-pty) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`pty`](../packages/pty/pty), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
-| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-subprocess`](../packages/subagent/subagent-subprocess) |
+| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) |
| [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
| [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-acp/cordis.yml b/examples/acp-agent/tests/fixtures/subagent/subagent-acp/cordis.yml
index 3bd5f5393c..c69cf574f7 100644
--- a/examples/acp-agent/tests/fixtures/subagent/subagent-acp/cordis.yml
+++ b/examples/acp-agent/tests/fixtures/subagent/subagent-acp/cordis.yml
@@ -10,6 +10,10 @@
- id: subagent
name: '@deepseek-ai/dsh-subagent'
+# The out-of-process ACP backend spawns its child through the subprocess seam.
+- id: subprocess
+ name: '@deepseek-ai/dsh-subprocess-local'
+
- id: subagent-acp
name: '@deepseek-ai/dsh-subagent-acp'
config:
diff --git a/knip.json b/knip.json
index 110abb3a2b..09b9544a36 100644
--- a/knip.json
+++ b/knip.json
@@ -6,7 +6,8 @@
"ignoreBinaries": [
"bwrap",
"python3",
- "sandbox-exec"
+ "sandbox-exec",
+ "taskkill"
],
"ignoreWorkspaces": [
"vendor/*",
@@ -262,8 +263,14 @@
]
},
"packages/session-query/session-query-sqlite": {
- "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
- "project": ["src/**/*.ts", "tests/**/*.ts"]
+ "entry": [
+ "tests/**/*.spec.ts",
+ "tests/**/*.e2e.ts"
+ ],
+ "project": [
+ "src/**/*.ts",
+ "tests/**/*.ts"
+ ]
},
"packages/code-runtime/code-runtime-worker": {
"entry": [
@@ -316,8 +323,14 @@
]
},
"packages/session-persistence/session-checkpoint-policy": {
- "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
- "project": ["src/**/*.ts", "tests/**/*.ts"]
+ "entry": [
+ "tests/**/*.spec.ts",
+ "tests/**/*.e2e.ts"
+ ],
+ "project": [
+ "src/**/*.ts",
+ "tests/**/*.ts"
+ ]
},
"packages/util/paths": {
"entry": [
@@ -488,15 +501,6 @@
"tests/**/*.ts"
]
},
- "packages/subagent/subagent-subprocess": {
- "entry": [
- "tests/**/*.spec.ts"
- ],
- "project": [
- "src/**/*.ts",
- "tests/**/*.ts"
- ]
- },
"packages/fs/tool-fs": {
"entry": [
"tests/**/*.spec.ts",
diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts
index 9d9ed676e2..f93c17e2ed 100644
--- a/packages/bash/bash-local/src/index.ts
+++ b/packages/bash/bash-local/src/index.ts
@@ -160,9 +160,11 @@ export class LocalBashExecutor extends BashExecutor {
/** The collect-mode readers the executor itself requested (present by construction). */
private static collected(handle: SubprocessHandle): { stdout: SubprocessOutputReader; stderr: SubprocessOutputReader } {
const { stdout, stderr } = handle.collected
+ /* v8 ignore start -- collect dispositions expose both readers by the seam contract; defensive. */
if (stdout === undefined || stderr === undefined) {
throw new Error('bash-local: subprocess implementation dropped a requested collect stream')
}
+ /* v8 ignore stop */
return { stdout, stderr }
}
diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts
index ec0d279bab..7d7ce8e5af 100644
--- a/packages/cordis/tool-cordis/src/api-catalog.ts
+++ b/packages/cordis/tool-cordis/src/api-catalog.ts
@@ -750,7 +750,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
methods: [
{
signature: 'abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle',
- jsDoc: '/**\n * Start one managed child process from a fully-specified spec; this seam\n * applies no defaults.\n * @param spec - argv, directory, limits, grace, cancellation, and environment.\n * @returns the live process handle (readers, kill, outcome promise).\n */',
+ jsDoc: '/**\n * Start one managed child process from a fully-specified spec; this seam\n * applies no defaults.\n * @param spec - argv, directory, stdio dispositions, grace, cancellation, and environment.\n * @returns the live process handle (streams/readers, signalling, outcome promise).\n */',
},
],
},
@@ -2210,13 +2210,29 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SubagentStopReasonMap',
declaration: 'export interface SubagentStopReasonMap {\n completed: \'completed\';\n aborted: \'aborted\';\n error: \'error\';\n \'max-tokens\': \'max-tokens\';\n refusal: \'refusal\';\n}',
},
+ {
+ name: 'SubprocessCollect',
+ declaration: 'export interface SubprocessCollect {\n maxBytes: number;\n spill?: {\n maxBytes: number;\n };\n}',
+ },
+ {
+ name: 'SubprocessCollectedOutputs',
+ declaration: 'export interface SubprocessCollectedOutputs {\n readonly stdout?: SubprocessOutputReader;\n readonly stderr?: SubprocessOutputReader;\n}',
+ },
+ {
+ name: 'SubprocessDisposeGraces',
+ declaration: 'export interface SubprocessDisposeGraces {\n eofGraceMs: number;\n graceMs: number;\n}',
+ },
{
name: 'SubprocessHandle',
- declaration: 'export interface SubprocessHandle {\n readonly pid: number;\n readonly stdout: SubprocessOutputReader;\n readonly stderr: SubprocessOutputReader;\n readonly done: Promise;\n kill(): void;\n}',
+ 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;\n kill(signal?: NodeJS.Signals): void;\n terminate(): void;\n waitForExit(signal?: AbortSignal): Promise;\n dispose(graces: SubprocessDisposeGraces): Promise;\n}',
},
{
name: 'SubprocessOutcome',
- declaration: 'export interface SubprocessOutcome {\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n stdout: CollectedOutput;\n stderr: CollectedOutput;\n}',
+ declaration: 'export interface SubprocessOutcome {\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n}',
+ },
+ {
+ name: 'SubprocessOutputMode',
+ declaration: 'export type SubprocessOutputMode = \'pipe\' | \'inherit\' | SubprocessCollect;',
},
{
name: 'SubprocessOutputRead',
@@ -2228,7 +2244,15 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SubprocessSpawnSpec',
- declaration: 'export interface SubprocessSpawnSpec {\n argv: readonly string[];\n cwd: string;\n stdoutMaxBytes: number;\n stderrMaxBytes: number;\n maxSpillBytes: number;\n graceMs: number;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record | undefined;\n dshEnv?: DshEnvironment | undefined;\n}',
+ declaration: 'export interface SubprocessSpawnSpec {\n argv: readonly string[];\n cwd: string;\n stdio: SubprocessStdio;\n graceMs: number;\n signal?: AbortSignal | undefined;\n env?: Record | undefined;\n dshEnv?: DshEnvironment | undefined;\n}',
+ },
+ {
+ name: 'SubprocessStdinMode',
+ declaration: 'export type SubprocessStdinMode = \'ignore\' | \'pipe\' | {\n readonly data: string;\n};',
+ },
+ {
+ name: 'SubprocessStdio',
+ declaration: 'export interface SubprocessStdio {\n stdin: SubprocessStdinMode;\n stdout: SubprocessOutputMode;\n stderr: SubprocessOutputMode;\n}',
},
{
name: 'SurfaceEvent',
diff --git a/packages/lsp/lsp-local/package.json b/packages/lsp/lsp-local/package.json
index 6815b1c917..1d6499d9f8 100644
--- a/packages/lsp/lsp-local/package.json
+++ b/packages/lsp/lsp-local/package.json
@@ -31,6 +31,7 @@
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-lsp": "^0.0.1",
+ "@deepseek-ai/dsh-subprocess": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
@@ -42,6 +43,8 @@
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-lsp": "workspace:^",
+ "@deepseek-ai/dsh-subprocess": "workspace:^",
+ "@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"cordis": "^4.0.0-rc.7",
"typescript": "^6.0.3",
diff --git a/packages/lsp/lsp-local/src/connection.ts b/packages/lsp/lsp-local/src/connection.ts
index 1103c4dbd2..cbad79ea0d 100644
--- a/packages/lsp/lsp-local/src/connection.ts
+++ b/packages/lsp/lsp-local/src/connection.ts
@@ -1,16 +1,17 @@
/**
- * A JSON-RPC endpoint over one spawned language server's stdio. Owns id correlation, outbound
- * requests/notifications, and inbound server→client requests: it answers `workspace/configuration`
- * from static config, and rejects `workspace/applyEdit` (this host never applies edits or runs
- * commands). It caps stderr, surfaces framing/decoder failures as a fatal close, and exposes the
- * child handle so the instance owns process-signal teardown.
+ * A JSON-RPC endpoint over one language server spawned through the subprocess
+ * seam. Owns id correlation, outbound requests/notifications, and inbound
+ * server→client requests: it answers `workspace/configuration` from static
+ * config, and rejects `workspace/applyEdit` (this host never applies edits or
+ * runs commands). It caps stderr, surfaces framing/decoder failures as a
+ * fatal close, and exposes tree-scoped termination through the handle so the
+ * instance owns teardown; group/tree mechanics live in the seam's
+ * implementation.
* @module @deepseek-ai/dsh-lsp-local/connection
*/
-import type { ChildProcessByStdio } from 'node:child_process'
-import { spawn, spawnSync } from 'node:child_process'
-import type { Readable, Writable } from 'node:stream'
-import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
+import type { Writable } from 'node:stream'
+import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import { encodeMessage, MessageDecoder } from './framing.ts'
/** How to launch the server and answer its config requests. */
@@ -27,6 +28,12 @@ export interface ConnectionSpec {
readonly maxMessageBytes: number
/** Largest stderr tail retained for diagnostics. */
readonly maxStderrBytes: number
+ /**
+ * Bound (ms) for draining pipes a surviving helper still holds after the
+ * server exits; the instance passes its kill grace so exit observation is
+ * never slower than the escalation it feeds.
+ */
+ readonly pipeDrainGraceMs: number
/** Static answer to every `workspace/configuration` item. */
readonly configuration: unknown
}
@@ -48,178 +55,89 @@ export type ConnectionWriter = (
done: (error?: Error | null) => void,
) => void
-/** Host operations used to signal a detached process tree. */
-export interface ProcessTreeOperations {
- /** Signal a POSIX process group. */
- readonly signal: (target: number, signal: NodeJS.Signals) => void
- /** Signal the direct child when POSIX group signaling is unavailable. */
- readonly killChild: (signal: NodeJS.Signals) => void
- /** Terminate a Windows process tree by root pid. */
- readonly taskkill: (pid: number) => void
-}
-
-/** Narrow taskkill runner result used by the Windows process-tree adapter. */
-export interface TaskkillResult {
- /** Process exit status, or null when spawning failed. */
- readonly status: number | null
- /** Spawn failure, when the executable could not run. */
- readonly error?: Error
-}
-
-/** Invoke a command synchronously for the Windows taskkill adapter. */
-export type TaskkillRunner = (
- command: string,
- args: string[],
- options: { stdio: 'ignore' },
-) => TaskkillResult
-
-/** Invoke the host process-signal primitive for a POSIX process group. */
-export type ProcessSignalRunner = (target: number, signal: NodeJS.Signals) => boolean
-
-const processSignalRunner: ProcessSignalRunner = process.kill.bind(process)
-
-/** taskkill status for "process not found": the requested process tree is already absent. */
-const TASKKILL_TREE_NOT_FOUND_STATUS = 128
+/** Spawn one subprocess for this connection (the provider passes `ctx.subprocess.spawn`). */
+export type ConnectionSpawner = (spec: SubprocessSpawnSpec) => SubprocessHandle
const writeConnectionMessage: ConnectionWriter = (stdin, message, done) => {
stdin.write(encodeMessage(message), done)
}
-/**
- * Terminate one Windows process tree and wait for taskkill to finish.
- * @param pid - root process id.
- * @param run - command runner; tests inject results without requiring Windows.
- */
-export function taskkillProcessTree(
- pid: number,
- run: TaskkillRunner = spawnSync,
-): void {
- const result = run('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' })
- if (result.error !== undefined) throw result.error
- if (result.status === TASKKILL_TREE_NOT_FOUND_STATUS) return
- if (result.status !== 0) throw new Error(`taskkill exited with status ${String(result.status)}`)
-}
-
-/**
- * Signal one POSIX process group through an injectable host primitive.
- * @param target - negative process-group id.
- * @param signal - requested signal.
- * @param run - host signal runner; tests inject it without touching real processes.
- */
-export function signalProcessGroup(
- target: number,
- signal: NodeJS.Signals,
- run: ProcessSignalRunner = processSignalRunner,
-): void {
- run(target, signal)
-}
-
-/**
- * Wait until a process-tree liveness probe reports exit.
- * @param isAlive - process-tree liveness probe.
- * @param signal - optional bound for the wait.
- * @param yieldNow - event-loop yield primitive.
- * @returns `true` when the tree exited, or `false` when the signal aborted first.
- */
-export async function waitForTreeExit(
- isAlive: () => boolean,
- signal?: AbortSignal,
- yieldNow: () => Promise = yieldToEventLoop,
-): Promise {
- while (isAlive()) {
- if (signal?.aborted) return false
- await yieldNow()
- }
- return true
-}
-
-/**
- * Signal a detached process tree with platform-correct semantics. POSIX falls back to the direct
- * child; Windows requires taskkill to reach the full tree.
- * @param platform - host platform.
- * @param pid - detached root process id.
- * @param signal - requested termination signal.
- * @param operations - host operations.
- */
-export function signalProcessTree(
- platform: NodeJS.Platform,
- pid: number,
- signal: NodeJS.Signals,
- operations: ProcessTreeOperations,
-): void {
- if (platform === 'win32') {
- operations.taskkill(pid)
- return
- }
- try {
- operations.signal(-pid, signal)
- } catch {
- try {
- operations.killChild(signal)
- } catch {
- // The direct child already exited; teardown remains idempotent.
- }
- }
-}
-
/** A live JSON-RPC endpoint bound to one child process. */
export class LspConnection {
- private readonly child: ChildProcessByStdio
+ private readonly handle: SubprocessHandle
+ private readonly stdin: Writable
private readonly decoder: MessageDecoder
private readonly pending = new Map()
private nextId = 1
- private stderr = Buffer.alloc(0)
private closeReason: Error | undefined
/** Set once the process has fully exited; the instance awaits it during teardown. */
readonly closed: Promise
/**
* @param spec - how to launch the server and answer its config requests.
+ * @param spawner - the subprocess seam's spawn (the provider passes `ctx.subprocess.spawn`).
* @param onServerRequest - answers a server→client request; rejects to send an error response.
* @param writer - message writer; tests inject callback failures without relying on OS pipe races.
*/
constructor(
- private readonly spec: ConnectionSpec,
+ spec: ConnectionSpec,
+ spawner: ConnectionSpawner,
private readonly onServerRequest: (method: string, params: unknown) => Promise,
private readonly writer: ConnectionWriter = writeConnectionMessage,
) {
this.decoder = new MessageDecoder(spec.maxMessageBytes)
- // `detached` gives teardown a process-tree root: POSIX signals its negative process-group id,
- // while Windows passes the root pid to taskkill /T so helpers such as tsserver cannot outlive it.
- this.child = spawn(spec.command, [...spec.args], {
+ // stdin/stdout are piped protocol streams this endpoint frames itself;
+ // stderr is a collected diagnostic tail (no spill — the bounded tail IS
+ // the contract). The seam owns detachment and tree-scoped signalling.
+ this.handle = spawner({
+ argv: [spec.command, ...spec.args],
cwd: spec.cwd,
+ stdio: {
+ stdin: 'pipe',
+ stdout: 'pipe',
+ stderr: { maxBytes: spec.maxStderrBytes },
+ },
+ graceMs: spec.pipeDrainGraceMs,
env: spec.env,
- stdio: ['pipe', 'pipe', 'pipe'],
- detached: true,
})
+ /* v8 ignore start -- 'pipe' dispositions expose both streams by the seam contract; defensive. */
+ if (this.handle.stdin === undefined || this.handle.stdout === undefined) {
+ throw new Error('lsp-local: subprocess implementation dropped a piped protocol stream')
+ }
+ /* v8 ignore stop */
+ this.stdin = this.handle.stdin
this.closed = new Promise((resolve) => {
- this.child.on('close', () => {
+ const close = (): void => {
const reason = this.closeReason ?? new Error(this.exitMessage())
// Record the reason so any request issued AFTER close rejects immediately instead of hanging
// (a closed process sends no further responses).
this.closeReason = reason
this.failAll(reason)
resolve()
+ }
+ this.handle.done.then(close, (error: unknown) => {
+ // A spawn-level failure never produces a close event; the rejection is
+ // the fatal cause and the close boundary at once.
+ this.fail(asError(error))
+ close()
})
})
- this.child.on('error', (error) => { this.fail(error) })
// Child stdin can fail while the process itself remains alive (for example, a server closes fd
// 0). Treat that as a fatal connection error so pending requests reject immediately instead of
// waiting for a process-close event that may never arrive.
- this.child.stdin.on('error', (error) => { this.fail(error) })
- this.child.stdout.on('data', (chunk: Buffer) => { this.onStdout(chunk) })
- this.child.stderr.on('data', (chunk: Buffer) => { this.onStderr(chunk) })
+ this.stdin.on('error', (error) => { this.fail(error) })
+ this.handle.stdout.on('data', (chunk: Buffer) => { this.onStdout(chunk) })
}
/** The child's pid, or `-1` when the spawn produced no pid (so signalling is a no-op). */
get pid(): number {
- /* v8 ignore next -- the `-1` fallback only applies to a spawn that produced no pid; defensive. */
- return this.child.pid ?? -1
+ return this.handle.pid
}
/** The retained stderr tail, for diagnostics on a failed server. */
get stderrTail(): string {
- return this.stderr.toString('utf8')
+ /* v8 ignore next -- the collect disposition always exposes a stderr reader; defensive. */
+ return this.handle.collected.stderr?.readFrom(0).text ?? ''
}
/** Whether the transport has failed even if the child close event has not arrived yet. */
@@ -289,14 +207,14 @@ export class LspConnection {
return this.nextId
}
- /** Request termination of the server's process tree. */
+ /** Request termination of the server's process tree (SIGTERM, no escalation). */
terminate(): void {
- this.signalTree('SIGTERM')
+ this.handle.kill('SIGTERM')
}
/** Force termination of the server's process tree. */
kill(): void {
- this.signalTree('SIGKILL')
+ this.handle.kill('SIGKILL')
}
/**
@@ -305,39 +223,7 @@ export class LspConnection {
* @returns `true` when the tree exited, or `false` when the signal aborted first.
*/
async waitForProcessTreeExit(signal?: AbortSignal): Promise {
- return await waitForTreeExit(this.processTreeAlive.bind(this), signal)
- }
-
- /** Signal the whole process tree. */
- private signalTree(sig: NodeJS.Signals): void {
- const pid = this.child.pid
- if (pid === undefined) return
- signalProcessTree(process.platform, pid, sig, {
- signal: signalProcessGroup,
- killChild: this.child.kill.bind(this.child),
- taskkill: taskkillProcessTree,
- })
- }
-
- /** Whether the detached tree's root or POSIX process group is still alive. */
- private processTreeAlive(): boolean {
- const pid = this.child.pid
- /* v8 ignore next -- only an asynchronous spawn failure omits pid; its close path owns cleanup. */
- if (pid === undefined) return false
- try {
- process.kill(-pid, 0)
- return true
- } catch (error) {
- const code = (error as NodeJS.ErrnoException).code
- /* v8 ignore next -- POSIX reports an absent group as ESRCH, but child-reaping timing makes
- whether lifecycle tests observe this branch platform-dependent. */
- if (code === 'ESRCH') return false
- /* v8 ignore start -- EPERM and non-POSIX negative-pid failures are platform defenses; CI runs
- process-group lifecycle tests on POSIX hosts where absence reports ESRCH. */
- if (code === 'EPERM') return true
- return this.child.exitCode === null && this.child.signalCode === null
- /* v8 ignore stop */
- }
+ return await this.handle.waitForExit(signal)
}
private onStdout(chunk: Buffer): void {
@@ -348,28 +234,12 @@ export class LspConnection {
// A framing/JSON failure corrupts the stream position irrecoverably: fail the instance and
// SIGKILL the whole group so helper processes don't outlive the leader.
this.fail(asError(error))
- this.signalTree('SIGKILL')
+ this.handle.kill('SIGKILL')
return
}
for (const message of messages) this.dispatch(message)
}
- private onStderr(chunk: Buffer): void {
- // Retain the TAIL, not the prefix: a language server's fatal diagnostic usually appears just
- // before it exits, so the final bounded segment is the useful one.
- const cap = this.spec.maxStderrBytes
- if (chunk.length >= cap) {
- // Copy the bounded suffix so retaining it does not pin an arbitrarily large incoming buffer.
- this.stderr = Buffer.from(chunk.subarray(chunk.length - cap))
- return
- }
- const retainedBytes = Math.min(this.stderr.length, cap - chunk.length)
- this.stderr = Buffer.concat([
- this.stderr.subarray(this.stderr.length - retainedBytes),
- chunk,
- ], retainedBytes + chunk.length)
- }
-
private dispatch(message: unknown): void {
if (message === null || typeof message !== 'object') return
const frame = message as Record
@@ -423,7 +293,7 @@ export class LspConnection {
reject(error)
}
try {
- this.writer(this.child.stdin, message, done)
+ this.writer(this.stdin, message, done)
/* v8 ignore start -- Node stream write failures are callback-delivered; this guards a
nonconforming Writable implementation throwing synchronously. */
} catch (error) {
diff --git a/packages/lsp/lsp-local/src/index.ts b/packages/lsp/lsp-local/src/index.ts
index dda3558130..b9699706aa 100644
--- a/packages/lsp/lsp-local/src/index.ts
+++ b/packages/lsp/lsp-local/src/index.ts
@@ -25,6 +25,8 @@ import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { abortable, abortError } from './abort.ts'
import { canonicalizeWorkspace, readHostSource } from './host.ts'
import { LspInstance } from './instance.ts'
+import type { ConnectionSpawner } from './connection.ts'
+import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
import type { InstanceSpec } from './instance.ts'
export { canonicalizeWorkspace, readHostSource } from './host.ts'
@@ -44,10 +46,10 @@ export { LspConnection } from './connection.ts'
export const name = 'lsp-local'
/** Services required by this plugin. */
-export const inject = ['lsp']
+export const inject = ['lsp', 'subprocess']
/** Credential-shaped ambient env vars are NOT forwarded to the child by default. */
-const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
+
const DEFAULT_MAX_MESSAGE_BYTES = 16_000_000
const DEFAULT_MAX_STDERR_BYTES = 1_000_000
@@ -127,7 +129,7 @@ export function apply(ctx: Context, config: Config): void {
validateServerConfig(providerId, resolved)
const childEnv = buildChildEnv(resolved.env)
const executable = resolveExecutable(resolved.command, childEnv)
- return new LocalLspProvider(providerId, resolved, childEnv, executable)
+ return new LocalLspProvider(providerId, resolved, childEnv, executable, spec => ctx.subprocess.spawn(spec))
})
ctx.effect(() => {
@@ -189,6 +191,7 @@ class LocalLspProvider implements LspProvider {
private readonly config: ResolvedServerConfig,
private readonly childEnv: Record,
private readonly executable: string,
+ private readonly spawner: ConnectionSpawner,
) {
this.id = LspProviderId(providerId)
this.extensionToLanguage = config.extensionToLanguage
@@ -282,10 +285,12 @@ class LocalLspProvider implements LspProvider {
initializationOptions: this.config.initializationOptions,
maxMessageBytes: this.config.maxMessageBytes,
maxStderrBytes: this.config.maxStderrBytes,
+ // Exit observation must never be slower than the escalation it feeds.
+ pipeDrainGraceMs: this.config.killGraceMs,
shutdownTimeoutMs: this.config.shutdownTimeoutMs,
killGraceMs: this.config.killGraceMs,
}
- return new LspInstance(spec)
+ return new LspInstance(spec, this.spawner)
}
/** Dispose every live instance and block further queries. */
@@ -302,12 +307,9 @@ class LocalLspProvider implements LspProvider {
}
}
-/** The ambient env minus credential-shaped vars, plus the config's explicit env. */
+/** The seam's scrubbed parent env (credential-shaped and DSH_* names dropped), plus the config's explicit env. */
function buildChildEnv(extra: Record): Record {
- const scrubbed = Object.entries(process.env).filter(
- ([key, value]) => value !== undefined && !SENSITIVE_ENV_PATTERN.test(key),
- ) as [string, string][]
- return { ...Object.fromEntries(scrubbed), ...extra }
+ return { ...scrubbedParentEnv(), ...extra }
}
/**
diff --git a/packages/lsp/lsp-local/src/instance.ts b/packages/lsp/lsp-local/src/instance.ts
index 266dd3c59f..0718bb6b82 100644
--- a/packages/lsp/lsp-local/src/instance.ts
+++ b/packages/lsp/lsp-local/src/instance.ts
@@ -17,7 +17,7 @@ import type {
import { deadline } from '@deepseek-ai/dsh-timeout'
import { abortable, abortError } from './abort.ts'
import { LspConnection } from './connection.ts'
-import type { ConnectionSpec, ConnectionWriter } from './connection.ts'
+import type { ConnectionSpawner, ConnectionSpec, ConnectionWriter } from './connection.ts'
import type { HostSource } from './host.ts'
import type { WireInitializeResult, WireServerCapabilities } from './protocol.ts'
import {
@@ -67,10 +67,11 @@ export class LspInstance {
/**
* @param spec - the launch, initialize, and teardown parameters.
+ * @param spawner - the subprocess seam's spawn function.
* @param writer - optional connection writer used by transport conformance tests.
*/
- constructor(private readonly spec: InstanceSpec, writer?: ConnectionWriter) {
- this.connection = new LspConnection(spec, (method, params) => this.answerServerRequest(method, params), writer)
+ constructor(private readonly spec: InstanceSpec, spawner: ConnectionSpawner, writer?: ConnectionWriter) {
+ this.connection = new LspConnection(spec, spawner, (method, params) => this.answerServerRequest(method, params), writer)
this.ready = this.initialize()
// A handshake rejection must not surface as an unhandled rejection before the first query awaits
// it; queries attach the real handler.
diff --git a/packages/lsp/lsp-local/tests/built-lib.e2e.ts b/packages/lsp/lsp-local/tests/built-lib.e2e.ts
index a2da86d87c..de9777269a 100644
--- a/packages/lsp/lsp-local/tests/built-lib.e2e.ts
+++ b/packages/lsp/lsp-local/tests/built-lib.e2e.ts
@@ -16,7 +16,8 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'
const pkgDir = fileURLToPath(new URL('..', import.meta.url))
const seamLib = join(pkgDir, '../lsp/lib/index.js')
-const built = existsSync(join(pkgDir, 'lib/index.js')) && existsSync(seamLib)
+const subprocessLib = join(pkgDir, '../../subprocess/subprocess-local/lib/index.js')
+const built = existsSync(join(pkgDir, 'lib/index.js')) && existsSync(seamLib) && existsSync(subprocessLib)
const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
@@ -41,8 +42,10 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => {
const { Context } = await import('cordis')
const { default: Lsp } = await import('@deepseek-ai/dsh-lsp')
const LspLocal = await import('@deepseek-ai/dsh-lsp-local')
+ const { default: LocalSubprocessService } = await import('@deepseek-ai/dsh-subprocess-local')
const ctx = new Context()
await ctx.plugin(Lsp)
+ await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LspLocal, {
servers: {
fake: {
diff --git a/packages/lsp/lsp-local/tests/connection.spec.ts b/packages/lsp/lsp-local/tests/connection.spec.ts
index aa7e819cb6..cdb331077c 100644
--- a/packages/lsp/lsp-local/tests/connection.spec.ts
+++ b/packages/lsp/lsp-local/tests/connection.spec.ts
@@ -1,18 +1,8 @@
-import { afterEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, describe, expect, it } from 'vitest'
import { fileURLToPath } from 'node:url'
import { LspConnection } from '@deepseek-ai/dsh-lsp-local'
-import {
- signalProcessGroup,
- signalProcessTree,
- taskkillProcessTree,
- waitForTreeExit,
-} from '@deepseek-ai/dsh-lsp-local/src/connection.ts'
-import type {
- ConnectionWriter,
- ProcessSignalRunner,
- ProcessTreeOperations,
- TaskkillRunner,
-} from '@deepseek-ai/dsh-lsp-local/src/connection.ts'
+import type { ConnectionWriter } from '@deepseek-ai/dsh-lsp-local/src/connection.ts'
+import { spawnSubprocess } from '@deepseek-ai/dsh-subprocess-local/src/spawn.ts'
const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
@@ -42,8 +32,9 @@ function connect(
env: { ...process.env as Record, ...env },
maxMessageBytes: 16_000_000,
maxStderrBytes: 100_000,
+ pipeDrainGraceMs: 3_000,
configuration: { setting: 42 },
- }, (method, params) => {
+ }, spawnSubprocess, (method, params) => {
seen?.push({ method, params })
return onServerRequest(method, params)
})
@@ -151,8 +142,9 @@ function connectScript(script: string, maxStderrBytes = 100_000, writer?: Connec
env: { ...process.env as Record },
maxMessageBytes: 16_000_000,
maxStderrBytes,
+ pipeDrainGraceMs: 3_000,
configuration: null,
- }, () => Promise.resolve(null), writer)
+ }, spawnSubprocess, () => Promise.resolve(null), writer)
open.push(conn)
return conn
}
@@ -166,8 +158,9 @@ describe('LspConnection edge behavior', () => {
env: {},
maxMessageBytes: 1000,
maxStderrBytes: 1000,
+ pipeDrainGraceMs: 3_000,
configuration: null,
- }, () => Promise.resolve(null))
+ }, spawnSubprocess, () => Promise.resolve(null))
open.push(conn)
await expect(conn.request('initialize', {})).rejects.toThrow()
})
@@ -248,72 +241,6 @@ describe('LspConnection edge behavior', () => {
})
})
-describe('process-tree signaling', () => {
- it('forwards POSIX process-group signals through the host runner', () => {
- const run: ProcessSignalRunner = vi.fn(() => true)
- signalProcessGroup(-42, 'SIGKILL', run)
- expect(run).toHaveBeenCalledWith(-42, 'SIGKILL')
- })
-
- it('waits for tree exit and stops when its bound aborts', async () => {
- const isAlive = vi.fn()
- .mockReturnValueOnce(true)
- .mockReturnValue(false)
- const yieldNow = vi.fn(() => Promise.resolve())
- await expect(waitForTreeExit(isAlive, undefined, yieldNow)).resolves.toBe(true)
- expect(yieldNow).toHaveBeenCalledOnce()
-
- const controller = new AbortController()
- controller.abort()
- await expect(waitForTreeExit(() => true, controller.signal, yieldNow)).resolves.toBe(false)
- })
-
- it('uses taskkill for a Windows tree and a negative pid for a POSIX group', () => {
- const operations = fakeProcessTreeOperations()
- signalProcessTree('win32', 42, 'SIGTERM', operations)
- expect(operations.taskkill).toHaveBeenCalledWith(42)
- expect(operations.signal).not.toHaveBeenCalled()
-
- signalProcessTree('linux', 42, 'SIGKILL', operations)
- expect(operations.signal).toHaveBeenCalledWith(-42, 'SIGKILL')
- })
-
- it('surfaces a Windows taskkill failure without downgrading to the direct child', () => {
- const fallback = fakeProcessTreeOperations()
- vi.mocked(fallback.taskkill).mockImplementation(() => { throw new Error('taskkill unavailable') })
- expect(() => { signalProcessTree('win32', 42, 'SIGTERM', fallback) }).toThrow(/taskkill unavailable/)
- expect(fallback.killChild).not.toHaveBeenCalled()
- })
-
- it('tolerates a POSIX tree-signaling race after the direct child is already gone', () => {
- const posixGone = fakeProcessTreeOperations()
- vi.mocked(posixGone.signal).mockImplementation(() => { throw new Error('group gone') })
- vi.mocked(posixGone.killChild).mockImplementation(() => { throw new Error('child gone') })
- expect(() => { signalProcessTree('linux', 42, 'SIGKILL', posixGone) }).not.toThrow()
- })
-
- it('runs taskkill for the full tree, accepts an absent tree, and rejects command failures', () => {
- const success: TaskkillRunner = vi.fn(() => ({ status: 0 }))
- taskkillProcessTree(42, success)
- expect(success).toHaveBeenCalledWith('taskkill', ['/PID', '42', '/T', '/F'], { stdio: 'ignore' })
-
- expect(() => { taskkillProcessTree(42, () => ({ status: 128 })) }).not.toThrow()
-
- const spawnFailure = new Error('cannot spawn taskkill')
- expect(() => { taskkillProcessTree(42, () => ({ status: null, error: spawnFailure })) }).toThrow(spawnFailure)
- expect(() => { taskkillProcessTree(42, () => ({ status: 1 })) }).toThrow(/status 1/)
- })
-})
-
-/** Create observable process-tree operations without touching host processes. */
-function fakeProcessTreeOperations(): ProcessTreeOperations {
- return {
- signal: vi.fn(),
- killChild: vi.fn(),
- taskkill: vi.fn(),
- }
-}
-
/** Poll a predicate until it holds or a deadline elapses. */
async function waitFor(predicate: () => boolean, timeoutMs = 3000): Promise {
const start = Date.now()
diff --git a/packages/lsp/lsp-local/tests/instance.spec.ts b/packages/lsp/lsp-local/tests/instance.spec.ts
index 9f246e602a..48dbd0252a 100644
--- a/packages/lsp/lsp-local/tests/instance.spec.ts
+++ b/packages/lsp/lsp-local/tests/instance.spec.ts
@@ -9,6 +9,7 @@ import type { ConnectionWriter } from '@deepseek-ai/dsh-lsp-local/src/connection
import { escalateProcessTree } from '@deepseek-ai/dsh-lsp-local/src/instance.ts'
import type { InstanceSpec } from '@deepseek-ai/dsh-lsp-local/src/instance.ts'
import type { LspProviderQuery, LspQueryResult } from '@deepseek-ai/dsh-lsp'
+import { spawnSubprocess } from '@deepseek-ai/dsh-subprocess-local/src/spawn.ts'
const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
@@ -43,10 +44,11 @@ function makeInstance(
initializationOptions: { init: true },
maxMessageBytes: 16_000_000,
maxStderrBytes: 100_000,
+ pipeDrainGraceMs: 200,
shutdownTimeoutMs: 200,
killGraceMs: 200,
...overrides,
- }, writer)
+ }, spawnSubprocess, writer)
live.push(instance)
return instance
}
@@ -72,10 +74,11 @@ function scriptInstance(script: string, overrides: Partial = {}):
initializationOptions: null,
maxMessageBytes: 16_000_000,
maxStderrBytes: 100_000,
+ pipeDrainGraceMs: 150,
shutdownTimeoutMs: 150,
killGraceMs: 150,
...overrides,
- })
+ }, spawnSubprocess)
live.push(instance)
return instance
}
diff --git a/packages/lsp/lsp-local/tests/lifecycle.spec.ts b/packages/lsp/lsp-local/tests/lifecycle.spec.ts
index 8a556a01e9..ccd85a1e14 100644
--- a/packages/lsp/lsp-local/tests/lifecycle.spec.ts
+++ b/packages/lsp/lsp-local/tests/lifecycle.spec.ts
@@ -7,6 +7,7 @@ import { pathToFileURL, fileURLToPath } from 'node:url'
import { Context } from 'cordis'
import Lsp, { type LspProvider, type LspQueryRequest, type LspQueryResult } from '@deepseek-ai/dsh-lsp'
import { deadline } from '@deepseek-ai/dsh-timeout'
+import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import * as LspLocal from '@deepseek-ai/dsh-lsp-local'
import type { LspLocalServerConfig } from '@deepseek-ai/dsh-lsp-local'
@@ -45,6 +46,7 @@ async function mount(
): Promise {
const ctx = new Context()
await ctx.plugin(Lsp)
+ await ctx.plugin(LocalSubprocessService)
const register = ctx.lsp.registerProvider.bind(ctx.lsp)
const registrationSpy = captureProvider === undefined
? undefined
@@ -76,6 +78,7 @@ describe('lsp-local end to end over a fake server', () => {
await writeFile(join(ws, 'a.py'), 'x = 1\n')
const ctx = new Context()
await ctx.plugin(Lsp)
+ await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LspLocal, {
servers: {
typescript: fakeServer({ LSP_FAKE_HOVER: JSON.stringify({ contents: 'ts' }) }),
@@ -318,6 +321,7 @@ describe('lsp-local end to end over a fake server', () => {
it('rejects at load when the command is not found', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
+ await ctx.plugin(LocalSubprocessService)
await expect(ctx.plugin(LspLocal, {
servers: {
missing: {
diff --git a/packages/lsp/lsp-local/tests/provider.spec.ts b/packages/lsp/lsp-local/tests/provider.spec.ts
index 829a84264b..77fcbe7851 100644
--- a/packages/lsp/lsp-local/tests/provider.spec.ts
+++ b/packages/lsp/lsp-local/tests/provider.spec.ts
@@ -3,6 +3,7 @@ import { chmod, mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises
import { tmpdir } from 'node:os'
import { delimiter, join } from 'node:path'
import { Context } from 'cordis'
+import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import Lsp, { type LspQueryRequest } from '@deepseek-ai/dsh-lsp'
import * as LspLocal from '@deepseek-ai/dsh-lsp-local'
import type { Config, LspLocalServerConfig } from '@deepseek-ai/dsh-lsp-local'
@@ -42,6 +43,7 @@ describe('lsp-local provider resolution', () => {
const ctx = new Context()
await ctx.plugin(Lsp)
+ await ctx.plugin(LocalSubprocessService)
await expect(ctx.plugin(LspLocal, config('onpath', {
command: 'fake-lsp',
args: [],
@@ -54,6 +56,7 @@ describe('lsp-local provider resolution', () => {
it('skips empty PATH segments and fails when the command is absent', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
+ await ctx.plugin(LocalSubprocessService)
await expect(ctx.plugin(LspLocal, config('nope', {
command: 'fake-lsp',
args: [],
@@ -67,6 +70,7 @@ describe('lsp-local provider resolution', () => {
// Use a server that never emits results and dispose the plugin, then confirm queries are refused.
const ctx = new Context()
await ctx.plugin(Lsp)
+ await ctx.plugin(LocalSubprocessService)
// Grab the provider instance by registering, then dispose the whole plugin fiber.
const lsp = ctx.lsp
const fiber = await ctx.plugin(LspLocal, config('disp', {
@@ -83,6 +87,7 @@ describe('lsp-local provider resolution', () => {
it('rejects a nonpositive teardown budget at load', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
+ await ctx.plugin(LocalSubprocessService)
await expect(ctx.plugin(LspLocal, config('bad-budget', {
command: process.execPath,
args: ['-e', ''],
@@ -95,6 +100,7 @@ describe('lsp-local provider resolution', () => {
it('rejects a nonpositive byte cap at load', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
+ await ctx.plugin(LocalSubprocessService)
await expect(ctx.plugin(LspLocal, config('bad-cap', {
command: process.execPath,
args: ['-e', ''],
@@ -107,6 +113,7 @@ describe('lsp-local provider resolution', () => {
it.each(['shutdownTimeoutMs', 'killGraceMs'] as const)('rejects %s above Node timer range at load', async (name) => {
const ctx = new Context()
await ctx.plugin(Lsp)
+ await ctx.plugin(LocalSubprocessService)
await expect(ctx.plugin(LspLocal, config('bad-timer', {
command: process.execPath,
args: ['-e', ''],
@@ -122,6 +129,7 @@ describe('lsp-local provider resolution', () => {
await writeFile(notExe, 'plain text, not executable')
const ctx = new Context()
await ctx.plugin(Lsp)
+ await ctx.plugin(LocalSubprocessService)
await expect(ctx.plugin(LspLocal, config('abs-bad', {
command: notExe,
args: [],
@@ -133,6 +141,7 @@ describe('lsp-local provider resolution', () => {
it('rejects an executable directory as a command at load', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
+ await ctx.plugin(LocalSubprocessService)
await expect(ctx.plugin(LspLocal, config('abs-directory', {
command: ws,
args: [],
@@ -144,6 +153,7 @@ describe('lsp-local provider resolution', () => {
it('rejects an empty server table at load', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
+ await ctx.plugin(LocalSubprocessService)
await expect(ctx.plugin(LspLocal, { servers: {} })).rejects.toThrow(/servers must contain at least one server/)
await ctx.fiber.dispose()
})
@@ -151,6 +161,7 @@ describe('lsp-local provider resolution', () => {
it('rejects an empty server id at load', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
+ await ctx.plugin(LocalSubprocessService)
await expect(ctx.plugin(LspLocal, config('', {
command: process.execPath,
extensionToLanguage: { '.ts': 'typescript' },
@@ -161,6 +172,7 @@ describe('lsp-local provider resolution', () => {
it('resolves every executable before publishing any provider', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
+ await ctx.plugin(LocalSubprocessService)
await expect(ctx.plugin(LspLocal, {
servers: {
valid: { command: process.execPath, extensionToLanguage: { '.ts': 'typescript' } },
@@ -174,6 +186,7 @@ describe('lsp-local provider resolution', () => {
it('rolls back earlier registrations when a later server conflicts', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
+ await ctx.plugin(LocalSubprocessService)
await expect(ctx.plugin(LspLocal, {
servers: {
first: { command: process.execPath, extensionToLanguage: { '.ts': 'typescript' } },
diff --git a/packages/lsp/lsp-local/tests/typescript-server.e2e.ts b/packages/lsp/lsp-local/tests/typescript-server.e2e.ts
index 8ba61c0717..fe9230ce14 100644
--- a/packages/lsp/lsp-local/tests/typescript-server.e2e.ts
+++ b/packages/lsp/lsp-local/tests/typescript-server.e2e.ts
@@ -10,6 +10,7 @@ import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
+import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import Lsp, { type LspQueryRequest, type LspQueryResult } from '@deepseek-ai/dsh-lsp'
import * as LspLocal from '@deepseek-ai/dsh-lsp-local'
@@ -52,6 +53,7 @@ beforeAll(async () => {
ctx = new Context()
await ctx.plugin(Lsp)
+ await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LspLocal, {
servers: {
typescript: {
diff --git a/packages/lsp/lsp-local/tsconfig.json b/packages/lsp/lsp-local/tsconfig.json
index 3a631ae288..2b106ddc81 100644
--- a/packages/lsp/lsp-local/tsconfig.json
+++ b/packages/lsp/lsp-local/tsconfig.json
@@ -29,6 +29,9 @@
{
"path": "../lsp"
},
+ {
+ "path": "../../subprocess/subprocess"
+ },
{
"path": "../../support/invariants"
}
diff --git a/packages/lsp/tool-lsp/tests/integration.spec.ts b/packages/lsp/tool-lsp/tests/integration.spec.ts
index 0a0265b490..4d790cbd6b 100644
--- a/packages/lsp/tool-lsp/tests/integration.spec.ts
+++ b/packages/lsp/tool-lsp/tests/integration.spec.ts
@@ -6,6 +6,7 @@ import { pathToFileURL } from 'node:url'
import { Context } from 'cordis'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
+import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import Lsp from '@deepseek-ai/dsh-lsp'
import * as LspLocal from '@deepseek-ai/dsh-lsp-local'
import * as TimeoutPolicy from '@deepseek-ai/dsh-timeout-policy'
@@ -49,6 +50,7 @@ async function mount(hang: boolean, timeoutMs?: number): Promise {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(Lsp)
+ await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LspLocal, {
servers: {
inline: {
diff --git a/packages/mcp/mcp-client/package.json b/packages/mcp/mcp-client/package.json
index 3480b48e52..0d214de9e8 100644
--- a/packages/mcp/mcp-client/package.json
+++ b/packages/mcp/mcp-client/package.json
@@ -29,6 +29,7 @@
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
+ "@deepseek-ai/dsh-subprocess": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
@@ -40,6 +41,7 @@
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
+ "@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@modelcontextprotocol/server-everything": "^2026.7.4",
"@modelcontextprotocol/server-filesystem": "^2026.7.4",
diff --git a/packages/mcp/mcp-client/src/transport.ts b/packages/mcp/mcp-client/src/transport.ts
index 6f7c584b20..f4bed91931 100644
--- a/packages/mcp/mcp-client/src/transport.ts
+++ b/packages/mcp/mcp-client/src/transport.ts
@@ -9,22 +9,17 @@
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
+import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
import type { Config } from './index.ts'
/**
- * Credential-shaped ambient env vars are NOT forwarded to the child by default
- * (the parent harness's own secrets must not leak into a spawned process
- * implicitly). Same pattern as `dsh-subagent-acp`.
+ * The subprocess seam's scrubbed parent env (credential-shaped and stale
+ * `DSH_*` names dropped), plus the spec's explicit env. The MCP SDK owns the
+ * actual spawn, so this transport shares the scrub definition rather than the
+ * spawn path.
*/
-const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
-
-/** The ambient env minus credential-shaped vars, plus the spec's explicit env. */
function buildChildEnv(extra: Record): Record {
- const env: Record = {}
- for (const [key, value] of Object.entries(process.env)) {
- if (value !== undefined && !SENSITIVE_ENV_PATTERN.test(key)) env[key] = value
- }
- return { ...env, ...extra }
+ return { ...scrubbedParentEnv(), ...extra }
}
/**
diff --git a/packages/mcp/mcp-client/tsconfig.json b/packages/mcp/mcp-client/tsconfig.json
index 668ee2c3cb..461b250297 100644
--- a/packages/mcp/mcp-client/tsconfig.json
+++ b/packages/mcp/mcp-client/tsconfig.json
@@ -21,6 +21,9 @@
{
"path": "../../core/tools"
},
+ {
+ "path": "../../subprocess/subprocess"
+ },
{
"path": "../../support/invariants"
}
diff --git a/packages/pty/pty-local/package.json b/packages/pty/pty-local/package.json
index fb26d845e5..e86024516e 100644
--- a/packages/pty/pty-local/package.json
+++ b/packages/pty/pty-local/package.json
@@ -36,6 +36,7 @@
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
+ "@deepseek-ai/dsh-subprocess": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
@@ -49,6 +50,7 @@
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
+ "@deepseek-ai/dsh-subprocess": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}
diff --git a/packages/pty/pty-local/src/index.ts b/packages/pty/pty-local/src/index.ts
index b466ecfc46..d768d6bfa8 100644
--- a/packages/pty/pty-local/src/index.ts
+++ b/packages/pty/pty-local/src/index.ts
@@ -10,6 +10,7 @@ import type { IPtyForkOptions } from 'node-pty'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import { PtyBackendCleanupError } from '@deepseek-ai/dsh-pty'
+import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
import type { PtyBackend, PtyBackendSpawnSpec } from '@deepseek-ai/dsh-pty'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
@@ -26,7 +27,6 @@ export const name = 'pty-local'
/** Required services: PTY registry plus the one shared confinement policy. */
export const inject = ['pty', 'sandbox', 'sandboxPolicy']
-const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
interface SandboxModeFenceState {
pty: Context['pty']
sandboxPolicy: Context['sandboxPolicy']
@@ -56,12 +56,9 @@ function ensureSandboxModeFence(ctx: Context, owner: Agent): void {
}
function childEnvironment(spec: PtyBackendSpawnSpec): NodeJS.ProcessEnv {
- const env: NodeJS.ProcessEnv = {}
- for (const [key, value] of Object.entries(process.env)) {
- if (value !== undefined && !SENSITIVE_ENV_PATTERN.test(key) && !key.startsWith('DSH_')) env[key] = value
- }
+ // node-pty owns the spawn; the base env shares the subprocess seam's scrub.
return {
- ...env,
+ ...scrubbedParentEnv(),
TERM: 'dumb',
PAGER: 'cat',
GIT_PAGER: 'cat',
diff --git a/packages/pty/pty-local/tsconfig.json b/packages/pty/pty-local/tsconfig.json
index 45a03248db..1580e4c8a2 100644
--- a/packages/pty/pty-local/tsconfig.json
+++ b/packages/pty/pty-local/tsconfig.json
@@ -32,6 +32,9 @@
{
"path": "../../sandbox/sandbox-policy"
},
+ {
+ "path": "../../subprocess/subprocess"
+ },
{
"path": "../../support/invariants"
}
diff --git a/packages/sdk/helper/package.json b/packages/sdk/helper/package.json
index 1d9c6a2be2..2884e04f72 100644
--- a/packages/sdk/helper/package.json
+++ b/packages/sdk/helper/package.json
@@ -35,6 +35,7 @@
"peerDependencies": {
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
+ "@deepseek-ai/dsh-subprocess": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
@@ -44,6 +45,7 @@
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^",
+ "@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-tool-subagent": "workspace:^",
"@deepseek-ai/dsh-tool-web": "workspace:^",
"cordis": "^4.0.0-rc.7"
diff --git a/packages/sdk/helper/src/package-managers/package-manager.ts b/packages/sdk/helper/src/package-managers/package-manager.ts
index 8d6b617977..3d67d194d3 100644
--- a/packages/sdk/helper/src/package-managers/package-manager.ts
+++ b/packages/sdk/helper/src/package-managers/package-manager.ts
@@ -5,6 +5,7 @@
*/
import { execFile, spawn } from 'node:child_process'
+import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
import { promisify } from 'node:util'
import type { PackageJsonFile } from '../documents/package-json-file.ts'
import { PnpmWorkspaceFile } from '../documents/pnpm-workspace-file.ts'
@@ -51,8 +52,14 @@ export async function probePackageManagerVersion(name: PackageManagerName, cwd:
}
}
-/** Remove credential-shaped environment variables from spawned commands. */
-export function scrubEnvironment(environment: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
+/**
+ * Remove credential-shaped environment variables from spawned commands.
+ * @param environment - source environment (injectable for tests); the default
+ * path shares the subprocess seam's scrub so every harness spawner drops the
+ * same names.
+ */
+export function scrubEnvironment(environment?: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
+ if (environment === undefined) return scrubbedParentEnv()
return Object.fromEntries(Object.entries(environment).filter(([name]) => !/(?:KEY|SECRET|TOKEN)/i.test(name)))
}
diff --git a/packages/sdk/helper/tsconfig.json b/packages/sdk/helper/tsconfig.json
index 18e79898c7..b1a3b7a61a 100644
--- a/packages/sdk/helper/tsconfig.json
+++ b/packages/sdk/helper/tsconfig.json
@@ -33,6 +33,9 @@
{
"path": "../../../vendor/cordis"
},
+ {
+ "path": "../../subprocess/subprocess"
+ },
{
"path": "../../support/invariants"
}
diff --git a/packages/subagent/README.md b/packages/subagent/README.md
index ccc6ab9cba..e03072fa78 100644
--- a/packages/subagent/README.md
+++ b/packages/subagent/README.md
@@ -8,10 +8,9 @@ The subagent seam: an agent delegating work to a child agent. Like the [bash](..
| `subagent-inprocess/` | Shared in-process run driver (no provider; one cleanup effect per run) | — |
| `subagent-spawn/` | In-process backend: a fresh child agent | (registers on `ctx.subagents`) |
| `subagent-fork/` | In-process backend: a child seeded with the parent's completed-turn prefix | (registers on `ctx.subagents`) |
-| `subagent-subprocess/` | Shared out-of-process machinery: env scrub, dispose ladder, isolated config dirs (pure lib; registers nothing) | — |
| `subagent-acp/` | Out-of-process backend: a child agent in a spawned subprocess, driven over ACP | (registers on `ctx.subagents`) |
| `tool-subagent/` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) |
-The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` backend builds on the `subagent-subprocess` library (the credential env scrub, the dispose ladder, isolated config dirs). Tests replace only the child boundary with package-local fixtures.
+The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` backend spawns its child through the [`subprocess/`](../subprocess/README.md) seam (the shared credential scrub, tree-scoped teardown, and dispose ladder). Tests replace only the child boundary with package-local fixtures.
The proposal and design rationale: [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md).
diff --git a/packages/subagent/subagent-acp/README.md b/packages/subagent/subagent-acp/README.md
index 45d9abe2b8..991773dadd 100644
--- a/packages/subagent/subagent-acp/README.md
+++ b/packages/subagent/subagent-acp/README.md
@@ -55,7 +55,7 @@ ACP advertises no start-time capabilities because this process cannot enforce th
## Process boundary
-The child environment is built by [`buildChildEnv`](../subagent-subprocess/README.md): credential-shaped ambient variables are removed, then explicit `config.env` values are applied. The ACP wire is the real serialization boundary; same-process subagent values are not defensively cloned.
+The child spawns through the [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam: credential-shaped ambient variables are removed by the shared scrub, then explicit `config.env` values merge after it (an intended `DEEPSEEK_API_KEY` survives), stderr is inherited to the parent's own stream, and disposal runs the seam's cooperative stdin-EOF→SIGTERM→SIGKILL ladder with this plugin's configured graces. The ACP wire is the real serialization boundary; same-process subagent values are not defensively cloned.
The package has no default export. Cordis loader unwrapping would otherwise hide the named `inject` metadata; see [postmortem 0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md).
diff --git a/packages/subagent/subagent-acp/package.json b/packages/subagent/subagent-acp/package.json
index 2564afa8da..b06a5e50ea 100644
--- a/packages/subagent/subagent-acp/package.json
+++ b/packages/subagent/subagent-acp/package.json
@@ -32,7 +32,7 @@
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-subagent": "^0.0.1",
- "@deepseek-ai/dsh-subagent-subprocess": "^0.0.1",
+ "@deepseek-ai/dsh-subprocess": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
@@ -47,7 +47,8 @@
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
- "@deepseek-ai/dsh-subagent-subprocess": "workspace:^",
+ "@deepseek-ai/dsh-subprocess": "workspace:^",
+ "@deepseek-ai/dsh-subprocess-local": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}
diff --git a/packages/subagent/subagent-acp/src/index.ts b/packages/subagent/subagent-acp/src/index.ts
index f8b3cd78c6..45605a8f2c 100644
--- a/packages/subagent/subagent-acp/src/index.ts
+++ b/packages/subagent/subagent-acp/src/index.ts
@@ -15,7 +15,7 @@ import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } fro
import { type AcpRunSpec, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, type PermissionPolicy, startAcpRun } from './run.ts'
export const name = 'subagent-acp'
-export const inject = ['subagents']
+export const inject = ['subagents', 'subprocess']
/** Config: how to spawn and drive the child ACP agent process. */
export interface Config {
@@ -152,6 +152,7 @@ class AcpProvider implements SubagentProvider {
env: this.config.env,
disposeEofGraceMs: this.config.disposeEofGraceMs,
disposeGraceMs: this.config.disposeGraceMs,
+ spawn: spec => this.ctx.subprocess.spawn(spec),
onError: (error, stopReason) => {
// The seam forbids `result` rejecting, so a child-level failure is
// flattened to a stop reason — preserve it here rather than losing it.
diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts
index 730505df84..aa9cd4f492 100644
--- a/packages/subagent/subagent-acp/src/run.ts
+++ b/packages/subagent/subagent-acp/src/run.ts
@@ -8,9 +8,8 @@
* @module @deepseek-ai/dsh-subagent-acp/run
*/
-import { spawn } from 'node:child_process'
import { randomUUID } from 'node:crypto'
-import { Readable, Writable } from 'node:stream'
+import { Readable as NodeReadable, Writable as NodeWritable } from 'node:stream'
import {
ClientSideConnection,
ndJsonStream,
@@ -26,7 +25,7 @@ import {
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
-import { buildChildEnv, disposeChildProcess, spawnFailure } from '@deepseek-ai/dsh-subagent-subprocess'
+import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
/** Fixed response to child permission requests: reject by default, or select the first allow option. */
export type PermissionPolicy = 'allow' | 'reject'
@@ -47,9 +46,9 @@ export interface AcpRunSpec {
permission: PermissionPolicy
/**
* Extra environment variables to ADD for the child (e.g. the child harness's
- * `DEEPSEEK_API_KEY`). Merged on top of the scrubbed ambient env — see
- * {@link buildChildEnv}. A value here is forwarded even if its name matches
- * the credential-scrub pattern (an explicit opt-in for the child's own creds).
+ * `DEEPSEEK_API_KEY`). Merged on top of the subprocess seam's scrubbed
+ * parent env. A value here is forwarded even if its name matches the
+ * credential-scrub pattern (an explicit opt-in for the child's own creds).
*/
env: Record
/**
@@ -65,6 +64,12 @@ export interface AcpRunSpec {
* fills this from its `disposeGraceMs` config.
*/
disposeGraceMs: number
+ /**
+ * Spawn function from the subprocess seam (`ctx.subprocess.spawn`), so the
+ * child rides the shared scrub, tree-scoped teardown, and service-owned
+ * lifetime instead of a package-local child_process path.
+ */
+ spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle
/**
* Sink for a child-level failure that the run flattened into a stop reason
* (the seam contract forbids `result` rejecting). The driver calls this with
@@ -159,20 +164,33 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
// each other or with a local agent that happens to use the same session id.
const id = SessionId(randomUUID())
- // Keep diagnostics on parent stderr; only ACP output contributes to the result.
- const child = spawn(spec.command, spec.args, {
+ // Keep diagnostics on parent stderr ('inherit'); only ACP output contributes
+ // to the result. The seam's scrub drops ambient credentials while spec.env
+ // (the child's own key) merges after it.
+ const child = spec.spawn({
+ argv: [spec.command, ...spec.args],
cwd: spec.cwd,
- env: buildChildEnv(spec.env),
- stdio: ['pipe', 'pipe', 'inherit'],
+ stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' },
+ graceMs: spec.disposeGraceMs,
+ env: spec.env,
})
- // Capture the child-process error event immediately.
- const spawnFailed = spawnFailure(child)
+ /* v8 ignore start -- 'pipe' dispositions expose both streams by the seam contract; defensive. */
+ if (child.stdin === undefined || child.stdout === undefined) {
+ throw new Error('subagent-acp: subprocess implementation dropped a piped protocol stream')
+ }
+ /* v8 ignore stop */
+ // Spawn-level failure surfaces as `done` rejecting into the startup race; a
+ // clean exit must never win it, so the success arm parks forever.
+ /* v8 ignore start -- the success arm's never-settling executor is intentionally empty. */
+ const spawnFailed: Promise = child.done.then(() => new Promise(() => {}), (err: unknown) => Promise.reject(toError(err)))
+ /* v8 ignore stop */
+ spawnFailed.catch(() => { /* observed by the startup race; never unhandled */ })
// Startup rollback and the published handle share one process teardown.
let processDisposal: Promise | undefined
- const disposeProcess = (): Promise => (processDisposal ??= disposeChildProcess(child, {
- disposeEofGraceMs: spec.disposeEofGraceMs,
- disposeGraceMs: spec.disposeGraceMs,
+ const disposeProcess = (): Promise => (processDisposal ??= child.dispose({
+ eofGraceMs: spec.disposeEofGraceMs,
+ graceMs: spec.disposeGraceMs,
}))
// Accumulate the child's streamed assistant text — the SubagentResult output.
@@ -207,8 +225,8 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
const conn = new ClientSideConnection(
makeClient,
ndJsonStream(
- Writable.toWeb(child.stdin) as WritableStream,
- Readable.toWeb(child.stdout) as ReadableStream,
+ NodeWritable.toWeb(child.stdin) as WritableStream,
+ NodeReadable.toWeb(child.stdout) as ReadableStream,
),
)
@@ -252,7 +270,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
sessionId = returnedSessionId
if (flags.cancelled) throw new Error('subagent cancelled before the ACP session started')
})(),
- spawnFailed.then((err): never => { throw err }),
+ spawnFailed,
cancelSettled.then((): never => { throw new Error('subagent cancelled before the ACP session started') }),
])
} catch (error: unknown) {
diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts b/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts
index 6f5a1e1c54..b3909b708d 100644
--- a/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts
+++ b/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts
@@ -21,7 +21,7 @@ const exampleConfig = fileURLToPath(new URL('../../../../examples/acp-agent/cord
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
// How to launch the child acp-agent (src via tsx / lib via plain node, per DSH_EXAMPLE_MODE).
-// buildChildEnv scrubs ambient creds but keeps these extras, so the model key is
+// The subprocess seam scrubs ambient creds while spec.env merges after it, so the model key is
// forwarded explicitly; TSX_TSCONFIG_PATH is added by the resolver in src mode only.
const childLaunch = resolveExampleLaunch({
srcBin: binScript,
diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts
index 79ef8831cf..919e736cb8 100644
--- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts
+++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts
@@ -6,10 +6,11 @@ import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import SubagentService from '@deepseek-ai/dsh-subagent'
-import { buildChildEnv } from '@deepseek-ai/dsh-subagent-subprocess'
import type { Agent } from '@deepseek-ai/dsh-agent'
import * as acp from '../src/index.ts'
import { acpStopReason, acpContentText, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, startAcpRun, toAcpPrompt, type AcpRunSpec } from '../src/run.ts'
+import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
+import { spawnSubprocess } from '@deepseek-ai/dsh-subprocess-local/src/spawn.ts'
/**
* Keyless integration tests for the ACP subagent backend. Each spawns a REAL
@@ -41,6 +42,7 @@ interface SetupEnv {
async function setup(mockEnv: SetupEnv = {}, permission: 'allow' | 'reject' = 'reject') {
const ctx = new Context()
await ctx.plugin(SubagentService)
+ await ctx.plugin(LocalSubprocessService)
await ctx.plugin(acp, {
providerName: 'acp',
command: process.execPath,
@@ -98,19 +100,23 @@ describe('acpContentText / toAcpPrompt', () => {
})
})
-describe('buildChildEnv', () => {
- it('drops credential-shaped ambient vars but keeps the explicit extras', () => {
- process.env.DSH_ACP_TEST_SECRET_TOKEN = 'leak-me'
+describe('child env layering (through the subprocess seam)', () => {
+ it('drops credential-shaped ambient vars but keeps the explicit extras', async () => {
+ process.env.ACP_TEST_AMBIENT_SECRET_TOKEN = 'leak-me'
try {
- const env = buildChildEnv({ DEEPSEEK_API_KEY: 'explicit' })
- // The credential-shaped ambient var is scrubbed.
- expect(env.DSH_ACP_TEST_SECRET_TOKEN).toBeUndefined()
- // The explicitly-supplied key survives (an opt-in for the child's creds).
- expect(env.DEEPSEEK_API_KEY).toBe('explicit')
- // A normal ambient var is forwarded.
- expect(env.PATH).toBe(process.env.PATH)
+ // The spec.env layer merges after the seam's scrub, so the child's own
+ // explicitly-forwarded key survives while ambient credentials do not.
+ const running = spawnSubprocess({
+ argv: ['bash', '-c', 'echo "[${ACP_TEST_AMBIENT_SECRET_TOKEN:-absent}|$DEEPSEEK_API_KEY]"'],
+ cwd: process.cwd(),
+ stdio: { stdin: 'ignore', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } },
+ graceMs: 1000,
+ env: { DEEPSEEK_API_KEY: 'explicit' },
+ })
+ await running.done
+ expect(running.collected.stdout!.readFrom(0).text.trim()).toBe('[absent|explicit]')
} finally {
- delete process.env.DSH_ACP_TEST_SECRET_TOKEN
+ delete process.env.ACP_TEST_AMBIENT_SECRET_TOKEN
}
})
})
@@ -140,6 +146,7 @@ describe('cwd resolution', () => {
try {
const ctx = new Context()
await ctx.plugin(SubagentService)
+ await ctx.plugin(LocalSubprocessService)
// A command that would create the sentinel if the child were ever spawned.
await ctx.plugin(acp, { providerName: 'acp', command: 'touch', args: [sentinel], permission: 'reject', env: {} })
const parent = { id: 'parent', session: { header: {} } } as unknown as Agent
@@ -158,6 +165,7 @@ describe('cwd resolution', () => {
try {
const ctx = new Context()
await ctx.plugin(SubagentService)
+ await ctx.plugin(LocalSubprocessService)
await ctx.plugin(acp, {
providerName: 'acp',
command: process.execPath,
@@ -185,6 +193,7 @@ describe('cwd resolution', () => {
const absolute = resolve(relative)
const ctx = new Context()
await ctx.plugin(SubagentService)
+ await ctx.plugin(LocalSubprocessService)
await ctx.plugin(acp, {
providerName: 'acp',
command: process.execPath,
@@ -204,6 +213,7 @@ describe('cwd resolution', () => {
// reintroduce the launch-directory fallback this resolution removed.
const ctx = new Context()
await ctx.plugin(SubagentService)
+ await ctx.plugin(LocalSubprocessService)
await expect(ctx.plugin(acp, {
providerName: 'acp',
command: 'true',
@@ -224,6 +234,7 @@ describe('cwd resolution', () => {
try {
const ctx = new Context()
await ctx.plugin(SubagentService)
+ await ctx.plugin(LocalSubprocessService)
await expect(ctx.plugin(acp, {
providerName: 'acp',
command: 'true',
@@ -242,6 +253,7 @@ describe('cwd resolution', () => {
it('rejects a config cwd that is not an accessible directory at load', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
+ await ctx.plugin(LocalSubprocessService)
await expect(ctx.plugin(acp, {
providerName: 'acp',
command: 'true',
@@ -283,6 +295,7 @@ describe('cwd resolution', () => {
try {
const ctx = new Context()
await ctx.plugin(SubagentService)
+ await ctx.plugin(LocalSubprocessService)
await ctx.plugin(acp, { providerName: 'acp', command: 'touch', args: [sentinel], permission: 'reject', env: {} })
const parent = { id: 'parent', session: { header: { cwd: join(tmp, 'vanished') } } } as unknown as Agent
await expect(ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }))
@@ -360,7 +373,7 @@ describe('dsh-subagent-acp', () => {
await expect(startAcpRun(
request('p', controller.signal),
// `touch ` — runs only if the process is actually spawned.
- { command: 'touch', args: [sentinel], cwd: tmp, permission: 'reject', env: {}, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS },
+ { command: 'touch', args: [sentinel], cwd: tmp, permission: 'reject', env: {}, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS, spawn: spawnSubprocess },
)).rejects.toThrow('aborted before the ACP child started')
// The binary was never launched — no sentinel.
expect(existsSync(sentinel)).toBe(false)
@@ -385,6 +398,7 @@ describe('dsh-subagent-acp', () => {
},
disposeEofGraceMs: 1000,
disposeGraceMs: 100,
+ spawn: spawnSubprocess,
})).rejects.toThrow('ACP child published without a session id')
// Startup rejects only after its private child reaches quiescence. The
// marker proves rollback closed stdin and allowed the child's EOF flush.
@@ -412,6 +426,7 @@ describe('dsh-subagent-acp', () => {
// small so the whole ladder finishes well within the 4000ms bound.
disposeEofGraceMs: 150,
disposeGraceMs: 150,
+ spawn: spawnSubprocess,
}
const run = await startAcpRun(request(), spec)
// Wait until the child has BOOTED AND ARMED THE TRAP (a condition, not a
@@ -459,6 +474,7 @@ describe('dsh-subagent-acp', () => {
},
disposeEofGraceMs: 2000,
disposeGraceMs: 50,
+ spawn: spawnSubprocess,
}
const run = await startAcpRun(request(), spec)
// Wait until the child is fully booted with its prompt in flight (its ACP
@@ -492,6 +508,7 @@ describe('dsh-subagent-acp', () => {
// Tiny EOF grace so the ignored-EOF window elapses quickly.
disposeEofGraceMs: 150,
disposeGraceMs: 2000,
+ spawn: spawnSubprocess,
}
const run = await startAcpRun(request(), spec)
await waitForFile(ready)
@@ -587,7 +604,7 @@ describe('dsh-subagent-acp', () => {
it('rejects a spawn failure after provider-owned cleanup', async () => {
await expect(startAcpRun(
request(),
- { command: '/nonexistent/acp-agent-binary', args: [], cwd: process.cwd(), permission: 'reject', env: {}, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS },
+ { command: '/nonexistent/acp-agent-binary', args: [], cwd: process.cwd(), permission: 'reject', env: {}, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS, spawn: spawnSubprocess },
)).rejects.toThrow()
})
@@ -601,6 +618,7 @@ describe('dsh-subagent-acp', () => {
try {
const ctx = new Context()
await ctx.plugin(SubagentService)
+ await ctx.plugin(LocalSubprocessService)
await ctx.plugin(acp, {
providerName: 'acp',
command: process.execPath,
@@ -626,6 +644,7 @@ describe('dsh-subagent-acp', () => {
for (const bad of [{ disposeEofGraceMs: 0 }, { disposeGraceMs: -1 }, { disposeEofGraceMs: Number.NaN }]) {
const ctx = new Context()
await ctx.plugin(SubagentService)
+ await ctx.plugin(LocalSubprocessService)
await expect(ctx.plugin(acp, { providerName: 'acp', command: 'true', args: [], permission: 'reject', env: {}, ...bad }))
.rejects.toThrow(/subagent-acp: dispose(?:Eof)?GraceMs must be a positive finite number/)
await ctx.fiber.dispose()
@@ -635,6 +654,7 @@ describe('dsh-subagent-acp', () => {
it('rejects a startup failure via the provider load path', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
+ await ctx.plugin(LocalSubprocessService)
await ctx.plugin(acp, {
providerName: 'acp',
command: '/nonexistent/acp-agent-binary',
@@ -661,6 +681,7 @@ describe('dsh-subagent-acp', () => {
env: { MOCK_CRASH_ON_PROMPT: '1' },
disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS,
disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
+ spawn: spawnSubprocess,
onError: (error, stopReason) => { errors.push({ message: error.message, stopReason }) },
},
)
@@ -699,6 +720,7 @@ describe('dsh-subagent-acp', () => {
env: { MOCK_CRASH_ON_PROMPT: '1' },
disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS,
disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
+ spawn: spawnSubprocess,
onError: () => { throw new Error('sink boom') },
},
)
@@ -763,6 +785,7 @@ describe('dsh-subagent-acp', () => {
it('unregisters the provider when its fiber is disposed (HMR safety)', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
+ await ctx.plugin(LocalSubprocessService)
const fiber = await ctx.plugin(acp, { providerName: 'acp', command: 'x', args: [], permission: 'reject', env: {} })
expect(ctx.subagents.list()).toEqual(['acp'])
await fiber.dispose()
@@ -772,7 +795,7 @@ describe('dsh-subagent-acp', () => {
it('has the namespace-plugin export shape (no stray default)', () => {
expect('default' in acp).toBe(false)
expect(acp.name).toBe('subagent-acp')
- expect(acp.inject).toEqual(['subagents'])
+ expect(acp.inject).toEqual(['subagents', 'subprocess'])
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(acp) as Record
expect(unwrapped).toBe(acp)
diff --git a/packages/subagent/subagent-acp/tsconfig.json b/packages/subagent/subagent-acp/tsconfig.json
index 175eb78e2f..2d60858d4a 100644
--- a/packages/subagent/subagent-acp/tsconfig.json
+++ b/packages/subagent/subagent-acp/tsconfig.json
@@ -27,7 +27,7 @@
"path": "../subagent"
},
{
- "path": "../subagent-subprocess"
+ "path": "../../subprocess/subprocess"
},
{
"path": "../../support/loader-smoke"
diff --git a/packages/subagent/subagent-subprocess/README.md b/packages/subagent/subagent-subprocess/README.md
deleted file mode 100644
index bd1900d612..0000000000
--- a/packages/subagent/subagent-subprocess/README.md
+++ /dev/null
@@ -1,53 +0,0 @@
-# @deepseek-ai/dsh-subagent-subprocess
-
-Shared machinery for **out-of-process subagent backends** — providers that spawn an external agent as a child process, such as the [ACP backend](../subagent-acp/README.md). A pure library (no provider, no registration, no Config): what every spawn-a-CLI-child backend needs to keep the parent deployment's credentials out of the child, tear the child down to quiescence, and isolate it from the host user's on-disk CLI state. Design rationale: [the Claude Code / Codex subagent backends Agent Note](../../../.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md).
-
-Every tunable is a **parameter**: the dispose ladder takes its grace periods per call, the config-dir helper takes an optional pinned path. Defaults live in each consuming plugin's Config (defaulted, validated fields changeable from `cordis.yml`), never in this library.
-
-## What it exports
-
-### `buildChildEnv(extra)`
-
-The credential env scrub (same pattern as the [bash executor](../../bash/bash-local/README.md)): the child env is the ambient env minus credential-shaped vars (`/KEY|SECRET|TOKEN/i`), with `extra` layered on top AFTER the scrub. `PATH`, `HOME`, `TMPDIR`, locale, and proxy vars survive, so the child CLI runs normally; the parent's own secrets never leak implicitly, while an explicitly supplied credential (the child's OWN key in a backend's `env` config) still reaches the child.
-
-### `spawnFailure(child)`
-
-Spawn-failure capture: a promise that resolves (never rejects) with the child's first `error` event. A spawn failure such as `ENOENT` is an event, not a thrown exception — without a listener Node crashes the parent process — so call this in the same tick as `spawn()` and race it in the run's result path; a bad command then settles as an ordinary child-level failure. For a child that spawns cleanly the promise never settles.
-
-### `disposeChildProcess(child, graces)`
-
-The platform-aware dispose ladder resolves only once the child has ACTUALLY exited — quiescence reached, not merely requested (see [defensive patterns](../../../docs/defensive-patterns.md)):
-
-1. stdin EOF (when stdin is piped), then wait `graces.disposeEofGraceMs` — a cooperative child quiesces on its own, its flushes and nested-subprocess teardown intact;
-2. on POSIX, `SIGTERM`, then wait `graces.disposeGraceMs`;
-3. force termination — `SIGKILL` on POSIX and Node's `TerminateProcess` mapping on Windows — then wait at most `graces.disposeGraceMs` for exit; a signal error or missing exit rejects disposal.
-
-The two graces (`DisposeLadderGraces`) come from the consuming plugin's `disposeEofGraceMs`/`disposeGraceMs` Config fields. POSIX uses `disposeGraceMs` after both the graceful and forced signals; Windows skips the redundant graceful signal but uses it to bound forced-exit confirmation. The EOF window is deliberately separate and usually wider, since cooperative teardown may await a signal-trapping grandchild plus a final flush.
-
-The exit waits are internal to this ladder. They clean up their timer and listener on either outcome, so escalation never accumulates listeners on the child.
-
-### `createIsolatedConfigDir(prefix, pinnedPath?)`
-
-A per-run isolated config directory for an external CLI child (the target of `CLAUDE_CONFIG_DIR` / `CODEX_HOME`-style redirection), so child behavior is a function of deployment config alone — never of whatever `~/.claude` / `~/.codex`-style state exists on the host. Returns an `IsolatedConfigDir` handle: `path` goes into the child env, `remove()` runs on dispose.
-
-- **Fresh (default)**: a private (0700) `mkdtemp` dir under the OS temp root; `remove()` deletes it best-effort (never rejects — a leftover temp dir beats a failed dispose) and is idempotent.
-- **Pinned** (`pinnedPath` set): the path is returned as-is — never created, never removed. A deployment that pins a directory to share child state across runs owns that directory's lifecycle.
-
-## Testing
-
-`tests/subagent-subprocess.spec.ts`: the env scrub and config-dir helpers run against the real process env and real filesystem (the rm-failure path injects its rejection at the fs boundary — a real recursive-rm failure is not portably provokable, and root ignores permission bits); the exit waits and platform termination paths run against a scriptable fake child. The [ACP backend suite](../subagent-acp/README.md) exercises them against real subprocesses end to end.
-
-## Model Experience
-
-Indirectly, through process-based subagent backends, whose child composition is constrained by credential scrubbing and isolated config directories.
-
-#### KV Cache effect
-
-No direct invalidation; the named consumer owns any request-prefix changes.
-
-## Known Limitations and Deferred Work
-
-- **The credential scrub is name-based** — only variables matching `KEY` / `SECRET` / `TOKEN` are removed; differently named secrets such as `PASSWORD` pass through unless the backend supplies a stricter environment.
-- **Signals target the direct child only** — teardown relies on a cooperative CLI to reap its descendants before exit; a re-parented or independently detached grandchild can outlive the ladder.
-- **Fresh config-dir cleanup is best-effort** — an `rm` failure leaves private state under the OS temp root rather than failing disposal.
-- **Pinned config directories are wholly operator-owned** — the helper neither creates, validates, locks, nor removes them, so concurrent runs may share and race on that state.
diff --git a/packages/subagent/subagent-subprocess/package.json b/packages/subagent/subagent-subprocess/package.json
deleted file mode 100644
index bd573b3c0c..0000000000
--- a/packages/subagent/subagent-subprocess/package.json
+++ /dev/null
@@ -1,37 +0,0 @@
-{
- "name": "@deepseek-ai/dsh-subagent-subprocess",
- "description": "Shared out-of-process subagent machinery: credential env scrub, spawn-failure capture, child-exit waits, the EOF-to-SIGTERM-to-SIGKILL dispose ladder, and isolated config dirs (pure lib; registers nothing)",
- "version": "0.0.1",
- "private": true,
- "type": "module",
- "main": "lib/index.js",
- "types": "lib/types/index.d.ts",
- "exports": {
- ".": {
- "types": "./lib/types/index.d.ts",
- "default": "./lib/index.js"
- },
- "./invariant": {
- "types": "./lib/types/invariant.d.ts",
- "default": "./lib/invariant.js"
- },
- "./src/*": "./src/*",
- "./package.json": "./package.json"
- },
- "files": [
- "lib/index.js",
- "lib/invariant.js",
- "lib/types/**/*.d.ts",
- "lib/types/**/*.d.ts.map",
- "src"
- ],
- "license": "BSD-3-Clause",
- "peerDependencies": {
- "@deepseek-ai/dsh-invariants": "^0.0.1",
- "cordis": "^4.0.0-rc.7"
- },
- "devDependencies": {
- "@deepseek-ai/dsh-invariants": "workspace:^",
- "cordis": "^4.0.0-rc.7"
- }
-}
diff --git a/packages/subagent/subagent-subprocess/src/index.ts b/packages/subagent/subagent-subprocess/src/index.ts
deleted file mode 100644
index 47a97bafb6..0000000000
--- a/packages/subagent/subagent-subprocess/src/index.ts
+++ /dev/null
@@ -1,223 +0,0 @@
-/**
- * Shared machinery for OUT-OF-PROCESS subagent backends — providers that spawn an external
- * agent as a child process and must keep the parent deployment's credentials out of it, tear
- * it down to quiescence, and isolate it from the host user's on-disk CLI state. This package
- * registers no provider; consuming plugins own and validate every timing or path default.
- * @module @deepseek-ai/dsh-subagent-subprocess
- */
-
-import type { ChildProcess } from 'node:child_process'
-import { mkdtemp, rm } from 'node:fs/promises'
-import { tmpdir } from 'node:os'
-import { join } from 'node:path'
-
-/**
- * Credential-shaped ambient env vars are NOT forwarded to a child by default
- * (the parent harness's own `DEEPSEEK_API_KEY`/secrets must not leak into a
- * spawned process implicitly). Same pattern as the bash executor. The child
- * agent needs its OWN credentials to reach a model — those are supplied
- * explicitly via the `extra` layer of {@link buildChildEnv}, which lands AFTER
- * the scrub, so an intended `DEEPSEEK_API_KEY` survives while an incidental
- * `AWS_SECRET_ACCESS_KEY` does not.
- */
-const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
-
-/**
- * The ambient env minus credential-shaped vars, plus the caller's explicit
- * env. `PATH`, `HOME`, `TMPDIR`, locale, and proxy vars survive the scrub, so
- * a child CLI runs normally; only credential-shaped names are dropped.
- * @param extra - explicit vars layered on top AFTER the scrub, so a
- * credential-shaped name supplied deliberately still reaches the child.
- * @returns the environment to spawn the child with.
- */
-export function buildChildEnv(extra: Record): NodeJS.ProcessEnv {
- const env: NodeJS.ProcessEnv = {}
- for (const [key, value] of Object.entries(process.env)) {
- if (!SENSITIVE_ENV_PATTERN.test(key)) env[key] = value
- }
- return { ...env, ...extra }
-}
-
-/**
- * Capture the child's spawn-level `error` event as a promise. Call in the same tick as
- * `spawn()`; otherwise an early event can be unhandled and crash the parent.
- * @param child - the just-spawned child process.
- * @returns a promise that RESOLVES (never rejects) with the child's first
- * `error` event; for a child that spawns cleanly it never settles.
- */
-export function spawnFailure(child: ChildProcess): Promise {
- return new Promise((resolve) => {
- child.once('error', (err) => { resolve(err) })
- })
-}
-
-/**
- * Race the child's exit against a timer. Neither outcome leaves anything
- * behind on the child: the exit listener is removed on timeout and the timer
- * is cleared on exit, so repeated calls (the dispose ladder's tiers, a poll
- * loop) never accumulate listeners.
- * @param child - the child process to watch.
- * @param ms - the wait window in milliseconds.
- * @returns `true` if the child exits within `ms` (immediately if it is
- * already gone), `false` on timeout.
- */
-function exitsWithin(child: ChildProcess, ms: number): Promise {
- if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true)
- return new Promise((resolve) => {
- const onExit = (): void => {
- clearTimeout(timer)
- resolve(true)
- }
- // `.unref()` so a pending grace timer never keeps the parent's loop alive.
- const timer = setTimeout(() => {
- child.removeListener('exit', onExit)
- resolve(false)
- }, ms).unref()
- child.once('exit', onExit)
- })
-}
-
-/**
- * The two grace periods of the dispose ladder, supplied per call by the
- * consuming backend — each plugin carries them as defaulted, validated
- * `disposeEofGraceMs`/`disposeGraceMs` Config fields, so teardown timing is
- * deployment-tunable and this library hardcodes nothing.
- */
-export interface DisposeLadderGraces {
- /**
- * Tier-1 window (ms): after stdin EOF, how long the child gets to quiesce
- * ON ITS OWN — flush durable state, tear down its own nested subprocesses —
- * before the parent escalates to platform termination. A separate (usually WIDER)
- * grace than {@link DisposeLadderGraces.disposeGraceMs}: a cooperative
- * child's EOF-driven teardown may itself be waiting on a signal-trapping
- * grandchild plus a final flush, needing more than one signal-grace of
- * headroom.
- */
- disposeEofGraceMs: number
- /**
- * Termination confirmation window (ms): POSIX applies it after `SIGTERM` and again after
- * `SIGKILL`; Windows applies it after the direct forced termination.
- */
- disposeGraceMs: number
-}
-
-/** Force-terminate a child and reject if no exit edge arrives within the configured grace. */
-function forceTerminateWithin(child: ChildProcess, ms: number): Promise {
- if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve()
- return new Promise((resolve, reject) => {
- let accepted = false
- let settled = false
- const cleanup = (): void => {
- clearTimeout(timer)
- child.off('exit', onExit)
- child.off('error', onError)
- }
- const settle = (complete: () => void): void => {
- if (settled) return
- settled = true
- cleanup()
- complete()
- }
- const onExit = (): void => { settle(resolve) }
- const onError = (error: Error): void => { settle(() => { reject(error) }) }
- child.once('exit', onExit)
- child.once('error', onError)
- const timer = setTimeout(() => {
- const disposition = accepted ? 'accepted' : 'refused'
- settle(() => {
- reject(new Error(`child process did not exit within ${ms}ms after SIGKILL was ${disposition}`))
- })
- }, ms).unref()
- try {
- accepted = child.kill('SIGKILL')
- if (child.exitCode !== null || child.signalCode !== null) settle(resolve)
- } catch (error: unknown) {
- settle(() => { reject(new Error('SIGKILL failed', { cause: error })) })
- }
- })
-}
-
-/**
- * Tear a child process down to quiescence, resolving only after exit: close stdin and allow
- * cooperative flush, then use the host's graceful and forced termination semantics. POSIX
- * sends `SIGTERM` before `SIGKILL`; Windows skips directly to forced termination because Node
- * maps both signals to `TerminateProcess`.
- *
- * @param child - the child process to tear down.
- * @param graces - the two grace periods, from the consuming plugin's Config.
- * @param platform - the host platform, injectable for unit coverage.
- * @throws When forced termination errors or the child does not report exit within
- * `disposeGraceMs`.
- */
-export async function disposeChildProcess(
- child: ChildProcess,
- graces: DisposeLadderGraces,
- platform: NodeJS.Platform = process.platform,
-): Promise {
- // Already gone: nothing to reap.
- if (child.exitCode !== null || child.signalCode !== null) return
- // 1. Close stdin and allow cooperative teardown and durable-state flush.
- child.stdin?.end()
- if (await exitsWithin(child, graces.disposeEofGraceMs)) return
- // 2. POSIX gets a catchable graceful signal; Windows signals all force-terminate.
- if (platform !== 'win32') {
- child.kill('SIGTERM')
- if (await exitsWithin(child, graces.disposeGraceMs)) return
- }
- // 3. Force-kill and await a bounded exit edge.
- await forceTerminateWithin(child, graces.disposeGraceMs)
-}
-
-/**
- * A per-run config directory handle for an external CLI child — the target of
- * `CLAUDE_CONFIG_DIR` / `CODEX_HOME`-style redirection. Hand {@link path} to
- * the child's environment; call {@link remove} on dispose.
- */
-export interface IsolatedConfigDir {
- /** The directory to point the child at. */
- path: string
- /**
- * Best-effort cleanup: removes the directory (recursively) iff this handle
- * CREATED it — a pinned directory is never removed. Idempotent; never
- * rejects (a leftover dir under the OS temp root is preferable to a failed
- * dispose).
- */
- remove(): Promise
-}
-
-/**
- * An isolated config dir for one child run, independent of host CLI state. Without
- * `pinnedPath`, creates a private temp directory and removes it best-effort; a pinned directory
- * is returned unchanged and remains deployment-owned.
- *
- * @param prefix - the `mkdtemp` name prefix for a fresh dir (e.g.
- * `dsh-subagent-codex-`); ignored when `pinnedPath` is set.
- * @param pinnedPath - a deployment-pinned directory to use instead of a
- * fresh one.
- * @returns the directory handle: `path` for the child env, `remove()` for
- * dispose.
- */
-export async function createIsolatedConfigDir(prefix: string, pinnedPath?: string): Promise {
- if (pinnedPath !== undefined) {
- return {
- path: pinnedPath,
- remove(): Promise {
- // A pinned dir is deployment-owned state (config the user asked to
- // persist across runs); removing it here would destroy it. No-op.
- return Promise.resolve()
- },
- }
- }
- const path = await mkdtemp(join(tmpdir(), prefix))
- return {
- path,
- async remove(): Promise {
- try {
- await rm(path, { recursive: true, force: true })
- } catch {
- // Best-effort by contract: swallows rm failures (EACCES/EBUSY-style — e.g. the dead
- // child left an unreadable entry behind).
- }
- },
- }
-}
diff --git a/packages/subagent/subagent-subprocess/src/invariant.ts b/packages/subagent/subagent-subprocess/src/invariant.ts
deleted file mode 100644
index 5e401cd738..0000000000
--- a/packages/subagent/subagent-subprocess/src/invariant.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-/**
- * Package-owned invariant companion for `@deepseek-ai/dsh-subagent-subprocess`.
- * @module @deepseek-ai/dsh-subagent-subprocess/invariant
- */
-
-/* jscpd:ignore-start */
-import type { Context } from 'cordis'
-import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
-
-const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-subprocess'
-
-/** Cordis companion plugin name. */
-export const name = 'subagent-subsubprocess-invariant'
-/** Service required before the companion can reserve package ownership. */
-export const inject = ['invariants']
-
-/**
- * No runtime invariant: this package exposes no independent event sequence or mutable data relation
- * beyond contracts enforced at its owning seam.
- */
-const install: InvariantInstaller = () => {}
-
-/**
- * Register this package's invariant companion.
- * @param ctx - Cordis context carrying the invariant service.
- * @returns the installed registration's disposer after setup succeeds.
- */
-export const apply = (ctx: Context): Promise<() => void> =>
- Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
-/* jscpd:ignore-end */
diff --git a/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts b/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts
deleted file mode 100644
index d674937e92..0000000000
--- a/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts
+++ /dev/null
@@ -1,389 +0,0 @@
-import { describe, expect, it, vi } from 'vitest'
-import { EventEmitter } from 'node:events'
-import { existsSync } from 'node:fs'
-import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises'
-import { tmpdir } from 'node:os'
-import { join } from 'node:path'
-import type { ChildProcess } from 'node:child_process'
-import {
- buildChildEnv,
- createIsolatedConfigDir,
- disposeChildProcess,
- spawnFailure,
-} from '../src/index.ts'
-
-// `rm` is real-passthrough except for one deterministic failure. Permission-based recursive-rm
-// failures are not portable and disappear under root, so this is the sanctioned filesystem seam.
-vi.mock('node:fs/promises', async (importOriginal) => {
- const actual = await importOriginal()
- return { ...actual, rm: vi.fn(actual.rm) }
-})
-
-/**
- * Unit tests for the shared out-of-process machinery. The env scrub and the
- * isolated-config-dir helpers run against the REAL process env and REAL
- * filesystem (one exception: the rm-failure path injects its rejection at the
- * mocked fs boundary, see above); the exit waits and the dispose ladder run
- * against a scriptable fake child so each escalation tier's timing is driven
- * deterministically (the ACP backend's suite exercises the same ladder
- * against real subprocesses end to end).
- */
-
-/** What fells a scripted {@link FakeChild}. */
-type LethalTrigger = 'eof' | NodeJS.Signals
-
-/** Per-scenario script for a {@link FakeChild}. */
-interface FakeChildScript {
- /**
- * The one trigger that makes the child exit (SIGKILL always does,
- * uncatchable, like a real process). Omitted: only SIGKILL fells it.
- */
- diesOn?: LethalTrigger
- /** Delay (ms) between the lethal trigger and the exit event. */
- delayMs?: number
- /** Complete the scripted exit inside the triggering call. */
- synchronousExit?: boolean
- /** `false` models a child spawned without a stdin pipe. */
- stdin?: boolean
-}
-
-/**
- * A scriptable stand-in for a ChildProcess carrying exactly the surface the
- * helpers read: `exitCode`/`signalCode`, `stdin.end()`, `kill()`, and the
- * `exit` event.
- */
-class FakeChild extends EventEmitter {
- exitCode: number | null = null
- signalCode: NodeJS.Signals | null = null
- readonly kills: NodeJS.Signals[] = []
- stdinEnded = false
- readonly stdin: { end: () => void } | null
-
- constructor(private readonly script: FakeChildScript = {}) {
- super()
- this.stdin = script.stdin === false
- ? null
- : { end: () => { this.stdinEnded = true; this.maybeDie('eof') } }
- }
-
- kill(signal: NodeJS.Signals): boolean {
- this.kills.push(signal)
- this.maybeDie(signal)
- return true
- }
-
- private maybeDie(trigger: LethalTrigger): void {
- // SIGKILL is uncatchable — it always fells the child; any other trigger
- // only when the scenario scripts it as the lethal one.
- if (trigger !== 'SIGKILL' && this.script.diesOn !== trigger) return
- const exit = (): void => {
- if (trigger === 'eof') this.exitCode = 0
- else this.signalCode = trigger
- this.emit('exit', this.exitCode, this.signalCode)
- }
- if (this.script.synchronousExit === true) exit()
- else setTimeout(exit, this.script.delayMs ?? 0)
- }
-}
-
-/** The helpers take a real ChildProcess; the fake carries the read surface. */
-function asChild(fake: FakeChild): ChildProcess {
- return fake as unknown as ChildProcess
-}
-
-describe('buildChildEnv', () => {
- it('drops credential-shaped ambient vars (KEY/SECRET/TOKEN, case-insensitive)', () => {
- process.env.DSH_PROC_TEST_API_KEY = 'leak'
- process.env.dsh_proc_test_secret = 'leak'
- process.env.DSH_PROC_TEST_TOKEN = 'leak'
- try {
- const env = buildChildEnv({})
- expect(env.DSH_PROC_TEST_API_KEY).toBeUndefined()
- expect(env.dsh_proc_test_secret).toBeUndefined()
- expect(env.DSH_PROC_TEST_TOKEN).toBeUndefined()
- } finally {
- delete process.env.DSH_PROC_TEST_API_KEY
- delete process.env.dsh_proc_test_secret
- delete process.env.DSH_PROC_TEST_TOKEN
- }
- })
-
- it('forwards normal ambient vars', () => {
- expect(buildChildEnv({}).PATH).toBe(process.env.PATH)
- })
-
- it('layers extras AFTER the scrub, so a deliberate credential-shaped name survives', () => {
- process.env.DSH_PROC_TEST_EXTRA_TOKEN = 'ambient-leak'
- try {
- const env = buildChildEnv({ DSH_PROC_TEST_EXTRA_TOKEN: 'explicit' })
- // The ambient value was scrubbed; ONLY the explicit opt-in reaches the child.
- expect(env.DSH_PROC_TEST_EXTRA_TOKEN).toBe('explicit')
- } finally {
- delete process.env.DSH_PROC_TEST_EXTRA_TOKEN
- }
- })
-
- it('an extra overrides the ambient value of a non-credential var', () => {
- process.env.DSH_PROC_TEST_PLAIN = 'ambient'
- try {
- expect(buildChildEnv({ DSH_PROC_TEST_PLAIN: 'override' }).DSH_PROC_TEST_PLAIN).toBe('override')
- } finally {
- delete process.env.DSH_PROC_TEST_PLAIN
- }
- })
-})
-
-describe('spawnFailure', () => {
- it('resolves (never rejects) with the first error event', async () => {
- const fake = new FakeChild()
- const failure = spawnFailure(asChild(fake))
- const err = new Error('spawn ENOENT')
- fake.emit('error', err)
- await expect(failure).resolves.toBe(err)
- })
-
- it('never settles for a child that spawns cleanly and exits', async () => {
- const fake = new FakeChild({ diesOn: 'SIGTERM' })
- const failure = spawnFailure(asChild(fake))
- fake.kill('SIGTERM')
- await new Promise(resolve => fake.once('exit', () => { resolve() }))
- // A clean lifecycle emits `exit`, never `error` — the capture stays
- // pending forever, so a race against it is decided by the other arms.
- const settled = await Promise.race([
- failure.then(() => 'settled'),
- new Promise(resolve => setTimeout(() => { resolve('pending') }, 30)),
- ])
- expect(settled).toBe('pending')
- })
-})
-
-describe('disposeChildProcess', () => {
- it('returns immediately for an already-exited child (no EOF, no signals)', async () => {
- const fake = new FakeChild()
- fake.exitCode = 0
- await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 })
- expect(fake.stdinEnded).toBe(false)
- expect(fake.kills).toEqual([])
- })
-
- it('returns immediately for a child already dead by signal', async () => {
- const fake = new FakeChild()
- fake.signalCode = 'SIGKILL'
- await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 })
- expect(fake.stdinEnded).toBe(false)
- expect(fake.kills).toEqual([])
- })
-
- it('tier 1: a cooperative child quiesces on stdin EOF — no signal is ever sent', async () => {
- const fake = new FakeChild({ diesOn: 'eof', delayMs: 5 })
- await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 })
- expect(fake.stdinEnded).toBe(true)
- expect(fake.kills).toEqual([])
- expect(fake.exitCode).toBe(0)
- })
-
- it('recognizes a child that exits synchronously on stdin EOF', async () => {
- const fake = new FakeChild({ diesOn: 'eof', synchronousExit: true })
- await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 })
- expect(fake.exitCode).toBe(0)
- expect(fake.listenerCount('exit')).toBe(0)
- })
-
- it('tier 2: a child that ignores EOF but honors SIGTERM dies on the middle rung', async () => {
- const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 })
- await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux')
- expect(fake.stdinEnded).toBe(true)
- expect(fake.kills).toEqual(['SIGTERM'])
- expect(fake.signalCode).toBe('SIGTERM')
- expect(fake.listenerCount('exit')).toBe(0)
- })
-
- it('recognizes a child that exits synchronously on SIGTERM', async () => {
- const fake = new FakeChild({ diesOn: 'SIGTERM', synchronousExit: true })
- await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux')
- expect(fake.kills).toEqual(['SIGTERM'])
- expect(fake.signalCode).toBe('SIGTERM')
- expect(fake.listenerCount('exit')).toBe(0)
- })
-
- it('tier 3: a SIGTERM-trapping child is SIGKILLed, and dispose resolves only after the exit', async () => {
- const fake = new FakeChild({ delayMs: 5 }) // only SIGKILL fells it
- await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 20 }, 'linux')
- expect(fake.kills).toEqual(['SIGTERM', 'SIGKILL'])
- // Quiescence, not a request: at resolution the child has ACTUALLY exited
- // (the exit event landed, despite the scripted post-SIGKILL delay).
- expect(fake.signalCode).toBe('SIGKILL')
- })
-
- it('recognizes a child already gone when the final exit wait begins', async () => {
- const fake = new FakeChild({ synchronousExit: true })
- await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 20 }, 'linux')
- expect(fake.kills).toEqual(['SIGTERM', 'SIGKILL'])
- expect(fake.signalCode).toBe('SIGKILL')
- })
-
- it.each(['exitCode', 'signalCode'] as const)('accepts a late OS %s marker before the final forced wait', async (marker) => {
- const fake = new FakeChild()
- vi.spyOn(fake, 'kill').mockImplementation((signal) => {
- fake.kills.push(signal)
- queueMicrotask(() => {
- if (marker === 'exitCode') fake.exitCode = 0
- else fake.signalCode = 'SIGTERM'
- })
- return true
- })
-
- await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1, disposeGraceMs: 10 }, 'linux')
- expect(fake.kills).toEqual(['SIGTERM'])
- })
-
- it('walks the ladder for a child spawned without a stdin pipe', async () => {
- const fake = new FakeChild({ stdin: false, diesOn: 'SIGTERM', delayMs: 5 })
- await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux')
- expect(fake.kills).toEqual(['SIGTERM'])
- })
-
- it('skips the redundant SIGTERM tier on Windows and awaits forced exit', async () => {
- const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 })
- await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'win32')
- expect(fake.kills).toEqual(['SIGKILL'])
- expect(fake.signalCode).toBe('SIGKILL')
- })
-
- it('propagates a forced-termination error without waiting for the grace', async () => {
- const fake = new FakeChild()
- const failure = Object.assign(new Error('kill EPERM'), { code: 'EPERM' })
- vi.spyOn(fake, 'kill').mockImplementation((signal) => {
- fake.kills.push(signal)
- fake.emit('error', failure)
- return false
- })
-
- await expect(disposeChildProcess(
- asChild(fake),
- { disposeEofGraceMs: 1, disposeGraceMs: 1000 },
- 'win32',
- )).rejects.toBe(failure)
- expect(fake.kills).toEqual(['SIGKILL'])
- expect(fake.listenerCount('error')).toBe(0)
- expect(fake.listenerCount('exit')).toBe(0)
- })
-
- it('wraps a synchronous forced-termination exception and removes its listeners', async () => {
- const fake = new FakeChild()
- const failure = new Error('invalid signal state')
- vi.spyOn(fake, 'kill').mockImplementation(() => { throw failure })
-
- await expect(disposeChildProcess(
- asChild(fake),
- { disposeEofGraceMs: 1, disposeGraceMs: 1000 },
- 'win32',
- )).rejects.toMatchObject({ message: 'SIGKILL failed', cause: failure })
- expect(fake.listenerCount('error')).toBe(0)
- expect(fake.listenerCount('exit')).toBe(0)
- })
-
- it('bounds a refused forced termination that produces no error or exit', async () => {
- const fake = new FakeChild()
- vi.spyOn(fake, 'kill').mockImplementation((signal) => {
- fake.kills.push(signal)
- return false
- })
-
- await expect(disposeChildProcess(
- asChild(fake),
- { disposeEofGraceMs: 1, disposeGraceMs: 10 },
- 'win32',
- )).rejects.toThrow('child process did not exit within 10ms after SIGKILL was refused')
- expect(fake.listenerCount('error')).toBe(0)
- expect(fake.listenerCount('exit')).toBe(0)
- })
-
- it('bounds an accepted forced termination that never reports exit', async () => {
- const fake = new FakeChild()
- vi.spyOn(fake, 'kill').mockImplementation((signal) => {
- fake.kills.push(signal)
- return true
- })
-
- await expect(disposeChildProcess(
- asChild(fake),
- { disposeEofGraceMs: 1, disposeGraceMs: 10 },
- 'win32',
- )).rejects.toThrow('child process did not exit within 10ms after SIGKILL was accepted')
- expect(fake.listenerCount('error')).toBe(0)
- expect(fake.listenerCount('exit')).toBe(0)
- })
-})
-
-describe('createIsolatedConfigDir', () => {
- it('creates a fresh private mkdtemp dir under the OS temp root', async () => {
- const dir = await createIsolatedConfigDir('dsh-subagent-subprocess-test-')
- try {
- expect(dir.path.startsWith(join(tmpdir(), 'dsh-subagent-subprocess-test-'))).toBe(true)
- const st = await stat(dir.path)
- expect(st.isDirectory()).toBe(true)
- // Windows reports synthetic POSIX mode bits; privacy comes from the
- // inherited directory ACL rather than chmod-compatible mode bits.
- if (process.platform !== 'win32') expect(st.mode & 0o777).toBe(0o700)
- } finally {
- await dir.remove()
- }
- })
-
- it('creates a distinct dir per call (per-run isolation)', async () => {
- const a = await createIsolatedConfigDir('dsh-subagent-subprocess-test-')
- const b = await createIsolatedConfigDir('dsh-subagent-subprocess-test-')
- try {
- expect(a.path).not.toBe(b.path)
- } finally {
- await a.remove()
- await b.remove()
- }
- })
-
- it('remove() deletes a fresh dir recursively and is idempotent', async () => {
- const dir = await createIsolatedConfigDir('dsh-subagent-subprocess-test-')
- await writeFile(join(dir.path, 'settings.json'), '{}')
- await dir.remove()
- expect(existsSync(dir.path)).toBe(false)
- // Second remove: nothing left to delete, still resolves.
- await expect(dir.remove()).resolves.toBeUndefined()
- })
-
- it('returns a pinned dir verbatim and NEVER removes it', async () => {
- const pinned = await mkdtemp(join(tmpdir(), 'dsh-subagent-subprocess-pinned-'))
- try {
- const dir = await createIsolatedConfigDir('ignored-prefix-', pinned)
- expect(dir.path).toBe(pinned)
- await dir.remove()
- // The deployment owns a pinned dir's lifecycle — remove() must not touch it.
- expect(existsSync(pinned)).toBe(true)
- } finally {
- await rm(pinned, { recursive: true, force: true })
- }
- })
-
- it('does not create a missing pinned path (the deployment owns its lifecycle)', async () => {
- const missing = join(tmpdir(), `dsh-subagent-subprocess-missing-${process.pid}`)
- const dir = await createIsolatedConfigDir('ignored-prefix-', missing)
- expect(dir.path).toBe(missing)
- expect(existsSync(missing)).toBe(false)
- await dir.remove()
- expect(existsSync(missing)).toBe(false)
- })
-
- it('remove() is best-effort: an rm rejection resolves instead of rejecting', async () => {
- const dir = await createIsolatedConfigDir('dsh-subagent-subprocess-locked-')
- try {
- // The swallow contract is error-kind agnostic; EACCES stands in for the
- // family (EBUSY, a vanished mount, …) that best-effort must absorb.
- vi.mocked(rm).mockRejectedValueOnce(Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }))
- await expect(dir.remove()).resolves.toBeUndefined()
- // The injected rejection consumed the only rm call — nothing was deleted.
- expect(existsSync(dir.path)).toBe(true)
- } finally {
- await rm(dir.path, { recursive: true, force: true })
- }
- })
-})
diff --git a/packages/subagent/subagent-subprocess/tsconfig.json b/packages/subagent/subagent-subprocess/tsconfig.json
deleted file mode 100644
index d970a00263..0000000000
--- a/packages/subagent/subagent-subprocess/tsconfig.json
+++ /dev/null
@@ -1,15 +0,0 @@
-{
- "extends": "../../../tsconfig.base.json",
- "compilerOptions": {
- "rootDir": "src",
- "outDir": "lib/types"
- },
- "include": [
- "src"
- ],
- "references": [
- {
- "path": "../../support/invariants"
- }
- ]
-}
diff --git a/packages/subprocess/README.md b/packages/subprocess/README.md
index 53b295d54e..5256065beb 100644
--- a/packages/subprocess/README.md
+++ b/packages/subprocess/README.md
@@ -1,10 +1,10 @@
# subprocess/ — subprocess capability family
-The shared home for spawning managed child-process groups: fully-specified spawn specs, bounded tail-keep output with spill files, credential-scrubbed environments, offset-based incremental reads, and SIGTERM→grace→SIGKILL group kills. Command defaulting, shell semantics, deadlines, and presentation stay with consumers — the [bash executor family](../bash/README.md) is the first and owning consumer. See the [subprocess seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md).
+The shared home for spawning managed child-process trees: fully-specified spawn specs with Node-shaped per-stream stdio dispositions (raw pipes, inherit, bounded tail-keep collection with spill files), the one credential scrub every harness spawner uses, offset-based incremental reads, tree-scoped signalling with SIGTERM→grace→SIGKILL escalation, and the cooperative dispose ladder. Command defaulting, shell semantics, deadlines, protocol framing, and presentation stay with consumers — the [bash executors](../bash/README.md), the [LSP host](../lsp/README.md), and the [ACP subagent backend](../subagent/README.md). See the [subprocess seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md).
| Package | ctx key | Role |
|---|---|---|
-| [`subprocess`](subprocess/README.md) (`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | The seam: abstract `SubprocessService.spawn(spec)`, the fully-explicit `SubprocessSpawnSpec`, `SubprocessHandle` with offset-based readers, and the shared `DSH_*` managed-environment and `CollectedOutput` vocabulary |
-| [`subprocess-local`](subprocess-local/README.md) (`@deepseek-ai/dsh-subprocess-local`) | — | The local implementation: detached process groups, tail-keep truncation with bounded private spill files, the credential scrub and `DSH_*` merge order, kill escalation, and kill-and-join disposal |
+| [`subprocess`](subprocess/README.md) (`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | The seam: abstract `SubprocessService.spawn(spec)`, the fully-explicit `SubprocessSpawnSpec` with per-stream stdio dispositions, `SubprocessHandle` (streams, offset-based readers, kill/terminate/waitForExit/dispose), and the shared scrub + `DSH_*`/`CollectedOutput` vocabulary |
+| [`subprocess-local`](subprocess-local/README.md) (`@deepseek-ai/dsh-subprocess-local`) | — | The local implementation: detached process trees, per-disposition stream wiring, tail-keep truncation with bounded private spill files, the `DSH_*` merge order, tree signalling with escalation, the dispose ladder, and terminate-and-join disposal |
The service owns process lifetime across consumer reloads; consumers own what a process means (a bash command, a future non-shell runner) and every default that shapes one.
diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md
index 4bc2a71691..eccd842396 100644
--- a/packages/subprocess/subprocess-local/README.md
+++ b/packages/subprocess/subprocess-local/README.md
@@ -1,14 +1,14 @@
# @deepseek-ai/dsh-subprocess-local
-Local implementation of the [`@deepseek-ai/dsh-subprocess`](../subprocess/README.md) seam: `LocalSubprocessService` spawns each spec's argv as a detached process group, collects bounded output with size-limited full-stream spill files, and escalates kills SIGTERM→SIGKILL across the whole group. It has no config: every limit and directory arrives on the spawn spec, so the deployment-varying knobs stay with the calling seam's config ([`dsh-bash-local`](../../bash/bash-local/README.md) today).
+Local implementation of the [`@deepseek-ai/dsh-subprocess`](../subprocess/README.md) seam: `LocalSubprocessService` spawns each spec's argv as a detached process tree, wires the spec's per-stream stdio dispositions (raw pipes, inherit, bounded tail-keep collection with optional spill files), and signals tree-scoped with SIGTERM→SIGKILL escalation. It has no config: every disposition, limit, and directory arrives on the spawn spec, so the deployment-varying knobs stay with the calling seams' configs ([`dsh-bash-local`](../../bash/bash-local/README.md), [`dsh-lsp-local`](../../lsp/lsp-local/README.md), [`dsh-subagent-acp`](../../subagent/subagent-acp/README.md)).
## Behavior (and where it came from)
-- **Detached process groups with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after the spec's grace (OpenCode's escalation; pipelines and subshells die with the parent). After the leader exits, inherited stdout/stderr pipes receive the same bounded drain grace so a surviving descendant cannot hold the spawn open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — the same caveat as the surveyed tools.
-- **Tail-keep truncation + bounded spill files** — output beyond a stream's cap keeps the in-memory TAIL (errors and results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a private temp file whose path is reported when available. A stream larger than the spill cap discards its now-incomplete spill and returns only the marked truncated tail; a failed final close withholds the path rather than advertising an incomplete file. Spill files are `0600` with random names under a lazily-created `0700` per-process directory.
+- **Detached process trees with platform-correct signalling** — POSIX children are spawned `detached` (own process group) and signalled by negative pgid with a direct-child fallback; Windows terminates the tree via `taskkill /PID /T /F` (injectable for tests). `terminate()` sends SIGTERM then SIGKILL after the spec's grace (OpenCode's escalation; pipelines and subshells die with the parent); `kill(signal)` sends exactly one signal and is a no-op after settlement; `dispose(graces)` runs stdin-EOF → SIGTERM → SIGKILL with caller-supplied windows and one memoized disposal per handle. After the leader exits, still-open pipes receive the same bounded drain grace so a surviving descendant cannot hold the outcome open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — the same caveat as the surveyed tools.
+- **Per-stream dispositions** — `'pipe'` hands the raw stream to the caller untouched (protocol framing stays consumer-owned); `'inherit'` passes the parent descriptor through; collect mode keeps the in-memory TAIL beyond its cap (errors and results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a private temp file when a spill cap is configured — omitting `spill` keeps only the tail, the diagnostic shape. A stream larger than the spill cap discards its now-incomplete spill and returns only the marked truncated tail; spill fds are sealed at settlement, and a failed final close withholds the path rather than advertising an incomplete file. Spill files are `0600` with random names under a lazily-created `0700` per-process directory.
- **Credential scrub + managed `DSH_*` merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; a spec's ordinary `env` merges after the scrub but rejects `DSH_*`; managed `dshEnv` rejects ordinary names and merges last, preventing stale nested-harness identity. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
-- **Offset-based reads** — `SubprocessHandle` readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist.
-- **Kill-and-join disposal** — the service retains live handles only so its own disposal can kill every running group and await its exit; settled and spawn-failed handles leave the live set on settlement.
+- **Offset-based reads** — collect-mode readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist, before and after settlement.
+- **Terminate-and-join disposal** — the service retains live handles only so its own disposal can escalate every running tree and await its exit; settled and spawn-failed handles leave the live set on settlement.
## Model Experience
diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts
index f939b9e774..99c527ace6 100644
--- a/packages/subprocess/subprocess-local/src/spawn.ts
+++ b/packages/subprocess/subprocess-local/src/spawn.ts
@@ -258,8 +258,9 @@ export function killGroup(pid: number, sig: NodeJS.Signals): void {
*/
export function taskkillProcessTree(pid: number): void {
if (pid <= 0) return
- // Outcome deliberately unchecked: an already-absent tree (status 128) and
- // exit races are as tolerable here as ESRCH is for a POSIX group signal.
+ // Outcome deliberately unchecked: an already-absent tree (status 128), exit
+ // races, and a missing taskkill binary (spawnSync reports, never throws) are
+ // as tolerable here as ESRCH is for a POSIX group signal.
spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' })
}
@@ -284,11 +285,14 @@ function signalTree(
try {
process.kill(-pid, sig)
} catch {
+ /* v8 ignore start -- the fallback needs a live child whose group signal fails
+ (EPERM-style), which POSIX CI cannot stage; the swallow keeps teardown idempotent. */
try {
child.kill(sig)
} catch {
// The direct child already exited; teardown remains idempotent.
}
+ /* v8 ignore stop */
}
}
@@ -360,6 +364,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
if (settled) return
signalTree(platform, pid, 'SIGTERM', child, taskkill)
graceTimer = setTimeout(() => {
+ /* v8 ignore next -- the timer is cleared at settlement; only an in-flight fire racing the close event sees settled=true. */
if (!settled) signalTree(platform, pid, 'SIGKILL', child, taskkill)
}, spec.graceMs)
}
@@ -422,6 +427,8 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
return true
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
+ /* v8 ignore next -- POSIX reports an absent group as ESRCH; child-reaping timing
+ makes observing the other arm platform-dependent. */
if (code === 'ESRCH') return false
/* v8 ignore start -- EPERM and non-POSIX negative-pid failures are platform defenses; CI runs
tree-lifecycle tests on POSIX hosts where absence reports ESRCH. */
@@ -442,7 +449,8 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
/** Race settlement against a timer without leaving listeners or live timers behind. */
const settlesWithin = async (ms: number): Promise => {
if (settled) return true
- let timer: NodeJS.Timeout | undefined
+ // The executor runs synchronously, so the timer is assigned before the race.
+ let timer!: NodeJS.Timeout
const timeout = new Promise((resolve) => {
// `.unref()` so a pending grace timer never keeps the parent's loop alive.
timer = setTimeout(() => { resolve(false) }, ms)
@@ -451,7 +459,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
try {
return await Promise.race([done.then(() => true, () => true), timeout])
} finally {
- if (timer !== undefined) clearTimeout(timer)
+ clearTimeout(timer)
}
}
@@ -474,9 +482,11 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
return {
pid,
+ /* v8 ignore start -- pipe-mode fds exist on every spawn Node returns; the null-coalesces guard a nonconforming ChildProcess only. */
stdin: stdinMode === 'pipe' ? child.stdin ?? undefined : undefined,
stdout: outMode === 'pipe' ? child.stdout ?? undefined : undefined,
stderr: errMode === 'pipe' ? child.stderr ?? undefined : undefined,
+ /* v8 ignore stop */
collected: {
...stdoutCollector !== undefined ? { stdout: stdoutCollector } : {},
...stderrCollector !== undefined ? { stderr: stderrCollector } : {},
diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts
index 2f48b0fef3..ecff00d229 100644
--- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts
+++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import type { DshEnvironment } from '@deepseek-ai/dsh-subprocess'
-import { killGroup, OutputCollector, spawnSubprocess } from '../src/spawn.ts'
+import { killGroup, OutputCollector, spawnSubprocess, taskkillProcessTree } from '../src/spawn.ts'
import type { SubprocessHandle, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess'
const { failNextClose, failNextUnlink } = vi.hoisted(() => ({
@@ -575,6 +575,147 @@ describe('waitForExit', () => {
})
})
+describe('coverage seams', () => {
+ it('taskkillProcessTree ignores non-positive pids and contains a missing binary', () => {
+ expect(() => { taskkillProcessTree(-1) }).not.toThrow()
+ expect(() => { taskkillProcessTree(0) }).not.toThrow()
+ // On POSIX there is no taskkill; spawnSync reports the failure in its
+ // result and the function stays silent — the same containment Windows
+ // relies on for an already-absent tree.
+ expect(() => { taskkillProcessTree(2 ** 30) }).not.toThrow()
+ })
+
+ it('dispose on a spawn-failed handle observes the rejection and returns', async () => {
+ const running = spawnSubprocess(spec('true', { cwd: '/nonexistent-dir-dsh-dispose-test' }))
+ const disposal = running.dispose({ eofGraceMs: 1_000, graceMs: 1_000 })
+ await expect(running.done).rejects.toThrow()
+ await expect(disposal).resolves.toBeUndefined()
+ })
+
+ it("an 'inherit' stdout with collected stderr wires only the requested collector", async () => {
+ const running = spawnSubprocess({
+ ...spec('echo to-parent; echo err >&2'),
+ stdio: { stdin: 'ignore', stdout: 'inherit', stderr: { maxBytes: 1000 } },
+ })
+ const outcome = await running.done
+ expect(outcome.exitCode).toBe(0)
+ expect(running.stdout).toBeUndefined()
+ expect(running.collected.stdout).toBeUndefined()
+ expect(running.collected.stderr!.readFrom(0).text).toBe('err\n')
+ })
+
+ it('terminate() after settlement is a no-op', async () => {
+ const running = spawnSubprocess(spec('true'))
+ await running.done
+ const spy = vi.spyOn(process, 'kill')
+ try {
+ running.terminate()
+ expect(spy).not.toHaveBeenCalled()
+ } finally {
+ spy.mockRestore()
+ }
+ })
+
+ it('waitForExit on a failed spawn reports exited immediately', async () => {
+ const running = spawnSubprocess(spec('true', { cwd: '/nonexistent-dir-dsh-spawn-test' }))
+ await expect(running.done).rejects.toThrow()
+ await expect(running.waitForExit()).resolves.toBe(true)
+ })
+
+ it('dispose() on an already-settled handle returns without signalling', async () => {
+ const running = spawnSubprocess(spec('true'))
+ await running.done
+ const spy = vi.spyOn(process, 'kill')
+ try {
+ await running.dispose({ eofGraceMs: 50, graceMs: 50 })
+ expect(spy).not.toHaveBeenCalled()
+ } finally {
+ spy.mockRestore()
+ }
+ })
+
+ it('a batch-stdin handle exposes no stdin and dispose skips the EOF tier', async () => {
+ const running = spawnSubprocess(spec('cat', { stdin: 'batch\n' }))
+ expect(running.stdin).toBeUndefined()
+ await running.done
+ await running.dispose({ eofGraceMs: 50, graceMs: 50 })
+ expect(running.collected.stdout!.readFrom(0).text).toBe('batch\n')
+ })
+})
+
+describe('coverage seams 2', () => {
+ it('win32 treeAlive reports alive for a live child and gone after taskkill', async () => {
+ let killedPid = 0
+ const running = spawnSubprocess(spec('sleep 60'), {
+ spillDir,
+ platform: 'win32',
+ taskkill: (pid) => {
+ killedPid = pid
+ try {
+ process.kill(pid, 'SIGKILL')
+ } catch {
+ // Already gone.
+ }
+ },
+ })
+ const aborted = new AbortController()
+ aborted.abort()
+ await expect(running.waitForExit(aborted.signal)).resolves.toBe(false) // alive branch
+ running.terminate()
+ await running.done
+ expect(killedPid).toBe(running.pid)
+ await expect(running.waitForExit()).resolves.toBe(true)
+ })
+
+ it('the win32 dispose ladder skips the POSIX SIGTERM tier and force-terminates', async () => {
+ const kills: number[] = []
+ const running = spawnSubprocess({
+ ...spec('sleep 60'),
+ stdio: { stdin: 'pipe', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } },
+ }, {
+ spillDir,
+ platform: 'win32',
+ taskkill: (pid) => {
+ kills.push(pid)
+ try {
+ process.kill(pid, 'SIGKILL')
+ } catch {
+ // Already gone.
+ }
+ },
+ })
+ await running.dispose({ eofGraceMs: 50, graceMs: 5_000 })
+ // Exactly one forced tree termination: no POSIX SIGTERM tier ran.
+ expect(kills).toEqual([running.pid])
+ })
+
+ it('dispose throws when even SIGKILL produces no exit within the grace', async () => {
+ // An inert taskkill simulates a tree that never reports exit.
+ const running = spawnSubprocess(spec('sleep 60'), { spillDir, platform: 'win32', taskkill: () => {} })
+ await expect(running.dispose({ eofGraceMs: 20, graceMs: 40 }))
+ .rejects.toThrow(/did not exit within 40ms after forced termination/)
+ // Real cleanup: the injected platform spawned without detachment, so the
+ // child is a plain (group-less) POSIX process — kill it directly.
+ process.kill(running.pid, 'SIGKILL')
+ await running.done
+ })
+
+ it("stderr: 'pipe' exposes the raw stream", async () => {
+ const running = spawnSubprocess({
+ ...spec('echo err >&2'),
+ stdio: { stdin: 'ignore', stdout: { maxBytes: 1000 }, stderr: 'pipe' },
+ })
+ expect(running.stderr).toBeDefined()
+ const text = new Promise((resolve) => {
+ let out = ''
+ running.stderr!.on('data', (chunk: Buffer) => { out += chunk.toString('utf8') })
+ running.stderr!.on('end', () => { resolve(out) })
+ })
+ await running.done
+ expect(await text).toBe('err\n')
+ })
+})
+
describe('argv validation', () => {
it('rejects an empty argv before spawning', () => {
expect(() => spawnSubprocess({ ...spec('true'), argv: [] })).toThrow(/non-empty program name/)
diff --git a/packages/subprocess/subprocess/README.md b/packages/subprocess/subprocess/README.md
index 15165a730d..5c45253154 100644
--- a/packages/subprocess/subprocess/README.md
+++ b/packages/subprocess/subprocess/README.md
@@ -4,13 +4,14 @@ The subprocess seam (`ctx.subprocess`). The abstract `SubprocessService` exposes
## Contract
-- `spawn(spec)` returns immediately with a live handle; `done` resolves at process close and rejects only for spawn-level failures.
-- The spec is fully explicit — argv, cwd, per-stream byte caps, spill cap, grace — because deployment-varying defaults belong to the calling seam's config, not to a hidden subprocess-service default (the `dsh-bash` request/spec split is the owning template). `argv` is never shell-interpreted here; a consumer that wants a shell passes `['bash', '-c', command]` itself.
-- Output readers take whole-stream byte offsets and never consume: independent readers cannot steal one another's deltas. A read whose offset slid out of the in-memory tail is `lossy` and points at the full-stream spill file when one exists.
-- `kill()` and the spec's abort signal escalate SIGTERM→grace→SIGKILL across the whole detached group; the service reacts to the abort but never classifies why (callers own deadlines and cause classification).
-- Disposal kills all still-running managed processes and awaits their exit.
+- `spawn(spec)` returns immediately with a live handle; `done` resolves at process close with exit facts (`SubprocessOutcome` carries no output and no cause classification) and rejects only for spawn-level failures.
+- The spec is fully explicit — argv, cwd, per-stream stdio dispositions, grace — because deployment-varying defaults belong to the calling seam's config, not to a hidden subprocess-service default (the `dsh-bash` request/spec split is the owning template). `argv` is never shell-interpreted; a consumer that wants a shell passes `['bash', '-c', command]` itself.
+- Stdio is Node-shaped per stream: `'pipe'` hands the caller the raw stream for its own protocol framing (LSP JSON-RPC, ACP ndjson), `'inherit'` passes the parent descriptor through for diagnostics, and collect mode (`{ maxBytes, spill? }`) buffers a bounded tail with an optional full-stream spill file. Collect readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; a read whose offset slid out of the in-memory tail is `lossy` and points at the spill file when one exists. Collected output stays readable after settlement.
+- Termination is tree-scoped on every platform (POSIX detached groups with direct-child fallback; Windows `taskkill /T`): `kill(signal)` sends one signal Node-style and is a no-op after settlement, `terminate()` (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL, `waitForExit()` observes the whole tree, and `dispose(graces)` runs the cooperative stdin-EOF→SIGTERM→SIGKILL ladder out-of-process children need — the manager reacts but never classifies why (callers own deadlines and cause classification).
+- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` are the one shared scrub definition: ambient credential-shaped and `DSH_*` names are dropped, explicit `env` merges after the scrub (a deliberately forwarded key survives), and `dshEnv` carries current harness facts on its own validated channel. Spawners that cannot route through the service (node-pty backends, SDK-managed transports) import the function.
+- Disposal of the service terminates all still-running managed processes and awaits their exit.
-See the [process data-structure catalog](../../../docs/core-data-structures/subprocess.md) and the [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md).
+See the [subprocess data-structure catalog](../../../docs/core-data-structures/subprocess.md) and the [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md).
## Model Experience
@@ -22,5 +23,5 @@ No direct invalidation; the named consumers own any request-prefix changes.
## Known Limitations and Deferred Work
-- **One consumer family so far** — the seam's shape is proven against the bash executors only; the other in-repo spawn sites (LSP servers, PTY backends, subagent transports) keep their own bespoke process handling until their stream/lifecycle needs are re-examined against this contract.
-- **POSIX group semantics are assumed** — the handle vocabulary (`pid` as group leader, group kills, SIGTERM/SIGKILL escalation) has no Windows story.
+- **node-pty and SDK-managed spawns share only the scrub** — the PTY backend's terminal fork and the MCP SDK's own stdio transport cannot route their spawns through this seam (the library owns the fork/spawn call); they import `scrubbedParentEnv` so the environment policy stays single-sourced.
+- **The dispose ladder assumes stdin-EOF cooperation** — a child that quiesces on a different signal (SIGHUP conventions, control sockets) needs its own tier-1 before the generic ladder fits.
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 82f3691044..d85d124931 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2526,6 +2526,12 @@ importers:
'@deepseek-ai/dsh-lsp':
specifier: workspace:^
version: link:../lsp
+ '@deepseek-ai/dsh-subprocess':
+ specifier: workspace:^
+ version: link:../../subprocess/subprocess
+ '@deepseek-ai/dsh-subprocess-local':
+ specifier: workspace:^
+ version: link:../../subprocess/subprocess-local
'@deepseek-ai/dsh-timeout':
specifier: workspace:^
version: link:../../util/timeout
@@ -2597,6 +2603,9 @@ importers:
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
+ '@deepseek-ai/dsh-subprocess':
+ specifier: workspace:^
+ version: link:../../subprocess/subprocess
'@deepseek-ai/dsh-tools':
specifier: workspace:^
version: link:../../core/tools
@@ -2691,6 +2700,9 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
+ '@deepseek-ai/dsh-subprocess':
+ specifier: workspace:^
+ version: link:../../subprocess/subprocess
cordis:
specifier: ^4.0.0-rc.7
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
@@ -2858,6 +2870,9 @@ importers:
'@deepseek-ai/dsh-session-persistence-sqlite':
specifier: workspace:^
version: link:../../session-persistence/session-persistence-sqlite
+ '@deepseek-ai/dsh-subprocess':
+ specifier: workspace:^
+ version: link:../../subprocess/subprocess
'@deepseek-ai/dsh-tool-subagent':
specifier: workspace:^
version: link:../../subagent/tool-subagent
@@ -3492,9 +3507,12 @@ importers:
'@deepseek-ai/dsh-subagent':
specifier: workspace:^
version: link:../subagent
- '@deepseek-ai/dsh-subagent-subprocess':
+ '@deepseek-ai/dsh-subprocess':
specifier: workspace:^
- version: link:../subagent-subprocess
+ version: link:../../subprocess/subprocess
+ '@deepseek-ai/dsh-subprocess-local':
+ specifier: workspace:^
+ version: link:../../subprocess/subprocess-local
cordis:
specifier: ^4.0.0-rc.7
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
@@ -3624,15 +3642,6 @@ importers:
specifier: ^4.0.0-rc.7
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
- packages/subagent/subagent-subprocess:
- devDependencies:
- '@deepseek-ai/dsh-invariants':
- specifier: workspace:^
- version: link:../../support/invariants
- cordis:
- specifier: ^4.0.0-rc.7
- version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
-
packages/subagent/tool-subagent:
dependencies:
schemastery:
@@ -4702,9 +4711,6 @@ importers:
'@deepseek-ai/dsh-subagent-spawn':
specifier: workspace:^
version: link:../../packages/subagent/subagent-spawn
- '@deepseek-ai/dsh-subagent-subprocess':
- specifier: workspace:^
- version: link:../../packages/subagent/subagent-subprocess
'@deepseek-ai/dsh-subprocess':
specifier: workspace:^
version: link:../../packages/subprocess/subprocess
diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json
index c2124da532..24e126a63d 100644
--- a/python/sdk-runtime/package.json
+++ b/python/sdk-runtime/package.json
@@ -65,7 +65,6 @@
"@deepseek-ai/dsh-subagent-fork": "workspace:^",
"@deepseek-ai/dsh-subagent-inprocess": "workspace:^",
"@deepseek-ai/dsh-subagent-spawn": "workspace:^",
- "@deepseek-ai/dsh-subagent-subprocess": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",
"@deepseek-ai/dsh-tasks-local": "workspace:^",
diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts
index 526e2ea5cd..58e63b5b3a 100644
--- a/scripts/gen-doc-graphs.ts
+++ b/scripts/gen-doc-graphs.ts
@@ -271,8 +271,8 @@ const SERVICE_ROLES: ServiceRole[] = [
title: 'Subprocess seam',
mode: 'seam',
implementations: ['subprocess-local'],
- consumers: ['bash-local', 'bash-sandbox'],
- note: 'The bash executors spawn their process groups through ctx.subprocess; the service owns group lifetime, bounded spill-backed output, and kill escalation.',
+ consumers: ['bash-local', 'bash-sandbox', 'lsp-local', 'subagent-acp'],
+ note: 'The bash executors, the LSP host, and the ACP subagent backend spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation.',
},
{
key: 'bash',
diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json
index 8dfde7ad11..f6a3ada2f3 100644
--- a/scripts/type-equiv.manifest.json
+++ b/scripts/type-equiv.manifest.json
@@ -2201,6 +2201,36 @@
"doc": "docs/core-data-structures/subprocess.md",
"symbol": "CollectedOutput",
"source": "packages/subprocess/subprocess/src/types.ts"
+ },
+ {
+ "doc": "docs/core-data-structures/subprocess.md",
+ "symbol": "SubprocessStdinMode",
+ "source": "packages/subprocess/subprocess/src/types.ts"
+ },
+ {
+ "doc": "docs/core-data-structures/subprocess.md",
+ "symbol": "SubprocessCollect",
+ "source": "packages/subprocess/subprocess/src/types.ts"
+ },
+ {
+ "doc": "docs/core-data-structures/subprocess.md",
+ "symbol": "SubprocessOutputMode",
+ "source": "packages/subprocess/subprocess/src/types.ts"
+ },
+ {
+ "doc": "docs/core-data-structures/subprocess.md",
+ "symbol": "SubprocessStdio",
+ "source": "packages/subprocess/subprocess/src/types.ts"
+ },
+ {
+ "doc": "docs/core-data-structures/subprocess.md",
+ "symbol": "SubprocessCollectedOutputs",
+ "source": "packages/subprocess/subprocess/src/types.ts"
+ },
+ {
+ "doc": "docs/core-data-structures/subprocess.md",
+ "symbol": "SubprocessDisposeGraces",
+ "source": "packages/subprocess/subprocess/src/types.ts"
}
]
}
diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts
index 0db4417fa5..024a82e933 100644
--- a/scripts/verify-package-readme-model-experience.ts
+++ b/scripts/verify-package-readme-model-experience.ts
@@ -87,7 +87,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = {
'packages/spill/spill': { kind: 'indirect', reason: 'The storage seam delegates model rendering to spill consumers.' },
'packages/spill/spill-local': { kind: 'indirect', reason: 'The storage backend delegates model rendering to spill consumers.' },
'packages/subagent/subagent': { kind: 'indirect', reason: 'The provider registry delegates parent-model rendering to dsh-tool-subagent.' },
- 'packages/subagent/subagent-subprocess': { kind: 'indirect', reason: 'Only process-based subagent backends compose a child model request.' },
'packages/support/acp-snapshot': { kind: 'none', reason: 'The test harness observes and normalizes transcripts without changing live requests.' },
'packages/support/agent-loop-testkit': { kind: 'none', reason: 'The test helper mounts services but neither drives nor modifies model requests.' },
'packages/support/invariants': { kind: 'none', reason: 'The observer validates requests but never rewrites their context.' },
diff --git a/tsconfig.host.json b/tsconfig.host.json
index a6397660a4..c769d3fcac 100644
--- a/tsconfig.host.json
+++ b/tsconfig.host.json
@@ -132,7 +132,6 @@
{ "path": "./packages/subagent/subagent" },
{ "path": "./packages/subagent/tool-subagent" },
{ "path": "./packages/subagent/subagent-inprocess" },
- { "path": "./packages/subagent/subagent-subprocess" },
{ "path": "./packages/subagent/subagent-spawn" },
{ "path": "./packages/subagent/subagent-fork" },
{ "path": "./packages/subagent/subagent-acp" },