From 0d6bfd88563fea212a08c9d94dc3690541c8f498 Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Sun, 26 Jul 2026 06:59:01 +0800
Subject: [PATCH] refactor(process): split the process manager out of the bash
executor
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
New process/ capability family: @deepseek-ai/dsh-process owns ctx.processes —
abstract ProcessManager.spawn(spec) over a fully-explicit ProcessSpawnSpec —
plus the shared DSH_* managed-environment and CollectedOutput vocabulary;
@deepseek-ai/dsh-process-local carries the former bash-local run.ts plumbing
(detached groups, tail-keep spill-backed output, credential scrub, kill
escalation, kill-and-join disposal) with no config of its own.
dsh-bash-local becomes a consumer: it keeps command defaulting, the fused
deadline timedOut/aborted classification, the model-friendly terminal env
(now merged through the ordinary env channel), and the [stderr]-marked
background read merge, and spawns through ctx.processes. Background-process
lifetime moves to the manager, so an executor reload no longer kills live
background work; a background spawn failure is injected once into the read
path instead of being buffered as fake stderr. dsh-bash re-exports the moved
vocabulary so bash consumers keep one import root; dsh-bash-sandbox only
redeclares the inherited inject.
Every composition loading a bash executor now loads dsh-process-local (CLI,
examples, python bundled runtime, create-sdk bash feature, inline test
configs).
---
.../2026-07-06-timeout-deadline-library.md | 2 +-
.../2026-07-06-timeout-deadline-library.zh.md | 2 +-
.../2026-07-26-process-manager-seam.md | 38 +++++
apps/cli/cordis.yml | 4 +
apps/cli/package.json | 1 +
docs/capability-seams.md | 12 +-
docs/config-catalog.md | 8 +-
docs/cordis-catalog/services.md | 27 +++-
docs/core-data-structures/bash.md | 28 +---
docs/core-data-structures/bash.zh.md | 28 +---
docs/core-data-structures/process.md | 133 +++++++++++++++++
docs/module-graph.md | 15 +-
examples/acp-agent/composition.md | 3 +
examples/acp-agent/cordis.yml | 4 +
examples/cordis-agent/composition.md | 3 +
examples/cordis-agent/cordis.yml | 4 +
examples/headless-agent/composition.md | 3 +
examples/headless-agent/cordis.yml | 4 +
.../semantic-checkpoint.cordis.snapshot.yml | 4 +
.../headless-agent/tests/code-mode.e2e.ts | 3 +
.../tests/fixtures/goal-domain/cordis.yml | 4 +
.../tests/fixtures/time-context.cordis.yml | 4 +
examples/headless-agent/tests/harness.ts | 2 +
examples/jsonrpc-agent/cordis.yml | 4 +
examples/package.json | 1 +
examples/tui-agent/composition.md | 3 +
examples/tui-agent/cordis.yml | 4 +
.../tests/fixtures/tui-scripted.cordis.yml | 4 +
examples/tui-agent/tests/tui.snapshot.ts | 2 +
packages/README.md | 1 +
packages/bash/README.md | 4 +-
packages/bash/bash-local/README.md | 21 ++-
packages/bash/bash-local/package.json | 3 +
packages/bash/bash-local/src/index.ts | 117 ++++++++-------
.../bash/bash-local/tests/executor.spec.ts | 50 ++++---
packages/bash/bash-local/tsconfig.json | 3 +
packages/bash/bash-sandbox/package.json | 1 +
packages/bash/bash-sandbox/src/index.ts | 4 +-
.../bash/bash-sandbox/tests/sandbox.spec.ts | 4 +-
packages/bash/bash/package.json | 2 +
packages/bash/bash/src/types.ts | 26 +---
packages/bash/bash/tsconfig.json | 3 +
packages/bash/tool-bash/package.json | 1 +
.../bash/tool-bash/tests/integration.spec.ts | 2 +
packages/bash/tool-bash/tests/tools.spec.ts | 14 +-
.../cordis/tool-cordis/src/api-catalog.ts | 30 ++++
.../examples/acp-demo/tests/built-bin.e2e.ts | 2 +
.../examples/acp-demo/tests/load-path.e2e.ts | 2 +
.../examples/cli-demo/tests/built-bin.e2e.ts | 2 +
packages/fs/tool-fs-search/package.json | 1 +
.../tool-fs-search/tests/integration.spec.ts | 2 +
packages/hooks/hooks-claude/package.json | 1 +
.../hooks/hooks-claude/tests/bridge.spec.ts | 4 +
.../hooks-claude/tests/coverage-cases.ts | 5 +
packages/hooks/hooks-codex/package.json | 1 +
.../hooks/hooks-codex/tests/bridge.spec.ts | 4 +
.../hooks/hooks-codex/tests/coverage-cases.ts | 4 +
packages/process/README.md | 10 ++
packages/process/process-local/README.md | 27 ++++
packages/process/process-local/package.json | 39 +++++
packages/process/process-local/src/index.ts | 53 +++++++
.../process/process-local/src/invariant.ts | 30 ++++
.../process-local/src/spawn.ts} | 140 ++++--------------
.../process-local/tests/manager.spec.ts | 70 +++++++++
.../process-local/tests/spawn.spec.ts} | 124 +++++++++-------
packages/process/process-local/tsconfig.json | 24 +++
packages/process/process/README.md | 26 ++++
packages/process/process/package.json | 37 +++++
packages/process/process/src/index.ts | 62 ++++++++
packages/process/process/src/invariant.ts | 22 +++
packages/process/process/src/types.ts | 128 ++++++++++++++++
.../process/process/tests/service.spec.ts | 55 +++++++
packages/process/process/tsconfig.json | 21 +++
.../sdk/helper/src/features/builtin/index.ts | 5 +-
packages/subagent/subagent-spawn/package.json | 1 +
.../subagent/subagent-spawn/tests/harness.ts | 2 +
pnpm-lock.yaml | 60 ++++++++
python/sdk-runtime/package.json | 2 +
.../runtime/cordis.yml | 4 +
python/sdk/tests/test_bundled_runtime.py | 2 +
scripts/doc-budgets.manifest.json | 2 +-
scripts/gen-cordis-catalog.ts | 7 +-
scripts/gen-doc-graphs.ts | 10 ++
scripts/gen-tool-catalog.ts | 2 +
scripts/type-equiv.manifest.json | 55 ++++---
.../verify-package-readme-model-experience.ts | 2 +
tsconfig.base.json | 2 +
tsconfig.host.json | 2 +
vitest.config.ts | 2 +-
89 files changed, 1331 insertions(+), 365 deletions(-)
create mode 100644 .agents/notes/implemented/architecture/2026-07-26-process-manager-seam.md
create mode 100644 docs/core-data-structures/process.md
create mode 100644 packages/process/README.md
create mode 100644 packages/process/process-local/README.md
create mode 100644 packages/process/process-local/package.json
create mode 100644 packages/process/process-local/src/index.ts
create mode 100644 packages/process/process-local/src/invariant.ts
rename packages/{bash/bash-local/src/run.ts => process/process-local/src/spawn.ts} (66%)
create mode 100644 packages/process/process-local/tests/manager.spec.ts
rename packages/{bash/bash-local/tests/run.spec.ts => process/process-local/tests/spawn.spec.ts} (78%)
create mode 100644 packages/process/process-local/tsconfig.json
create mode 100644 packages/process/process/README.md
create mode 100644 packages/process/process/package.json
create mode 100644 packages/process/process/src/index.ts
create mode 100644 packages/process/process/src/invariant.ts
create mode 100644 packages/process/process/src/types.ts
create mode 100644 packages/process/process/tests/service.spec.ts
create mode 100644 packages/process/process/tsconfig.json
diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md
index 11d4b8cd48..b459a7479e 100644
--- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md
+++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md
@@ -8,7 +8,7 @@ English | [中文](2026-07-06-timeout-deadline-library.zh.md)
Timeout handling was drifting apart across the tool-bearing capabilities, and the divergence was not superficial — it was the same logic re-implemented three ways, each with its own subtle correctness burden.
-- **bash** ([packages/bash/bash-local/src/run.ts](../../../../packages/bash/bash-local/src/run.ts)) had a full, correct timeout inside the process plumbing: a config-clamped `timeoutMs`, two independent triggers — a `killTimer` for the timeout and an `onAbort` listener for upstream cancellation — each calling one `kill()` closure that escalates SIGTERM→grace→SIGKILL on the process group, and two orthogonal outcome booleans (`timedOut`, `aborted`) latched independently.
+- **bash** ([packages/process/process-local/src/spawn.ts](../../../../packages/process/process-local/src/spawn.ts)) had a full, correct timeout inside the process plumbing: a config-clamped `timeoutMs`, two independent triggers — a `killTimer` for the timeout and an `onAbort` listener for upstream cancellation — each calling one `kill()` closure that escalates SIGTERM→grace→SIGKILL on the process group, and two orthogonal outcome booleans (`timedOut`, `aborted`) latched independently.
- **web_fetch** ([packages/web/web-fetch-local/src/provider.ts](../../../../packages/web/web-fetch-local/src/provider.ts)) had a correct but *hand-rolled* timeout: it constructed an `AbortController`, wired `setTimeout(() => controller.abort(new WebError(…, 'WEB_FETCH_TIMEOUT')))`, manually added and removed the upstream-signal listener, cleared the timer in a `finally`, and recovered the timeout reason from `signal.reason` in a `translateAbortOrNetwork` helper because the reader surfaces a bare `AbortError`.
- **web_search** ([packages/web/tool-web/src/search.ts](../../../../packages/web/tool-web/src/search.ts)) had **no timeout at all**: `WebSearchRequest` ([packages/web/web/src/types.ts](../../../../packages/web/web/src/types.ts)) carries no `timeoutMs` field, and each provider's `search()` only forwards `exec.signal`. (web_search stays untimed here — see Consequences.)
diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md
index 334914c689..e79ac6d7b6 100644
--- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md
@@ -8,7 +8,7 @@ Status: implemented
超时处理在各个承载工具的能力之间逐渐分化,而且这种分化并非表面的:同一套逻辑被以三种方式重新实现,各自带有微妙的正确性负担。
-- **bash**([packages/bash/bash-local/src/run.ts](../../../../packages/bash/bash-local/src/run.ts))在进程管道内部有一套完整、正确的超时实现:一个经配置钳位的 `timeoutMs`,两个独立触发器(用于超时的 `killTimer` 和用于上游取消的 `onAbort` 监听器),各自调用同一个 `kill()` 闭包对进程组执行 SIGTERM→宽限期→SIGKILL 升级,以及两个正交的结果布尔值(`timedOut`、`aborted`)独立锁存。
+- **bash**([packages/process/process-local/src/spawn.ts](../../../../packages/process/process-local/src/spawn.ts))在进程管道内部有一套完整、正确的超时实现:一个经配置钳位的 `timeoutMs`,两个独立触发器(用于超时的 `killTimer` 和用于上游取消的 `onAbort` 监听器),各自调用同一个 `kill()` 闭包对进程组执行 SIGTERM→宽限期→SIGKILL 升级,以及两个正交的结果布尔值(`timedOut`、`aborted`)独立锁存。
- **web_fetch**([packages/web/web-fetch-local/src/provider.ts](../../../../packages/web/web-fetch-local/src/provider.ts))有一套正确但*手写*的超时:构造一个 `AbortController`,连接 `setTimeout(() => controller.abort(new WebError(…, 'WEB_FETCH_TIMEOUT')))`,手动添加和移除上游信号监听器,在 `finally` 中清除定时器,并在 `translateAbortOrNetwork` 辅助函数中从 `signal.reason` 恢复超时原因(因为 reader 只抛出裸 `AbortError`)。
- **web_search**([packages/web/tool-web/src/search.ts](../../../../packages/web/tool-web/src/search.ts))**完全没有超时**:`WebSearchRequest`([packages/web/web/src/types.ts](../../../../packages/web/web/src/types.ts))不携带 `timeoutMs` 字段,各提供方的 `search()` 只转发 `exec.signal`。(web_search 在本次设计中保持无超时——见「后果」。)
diff --git a/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.md b/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.md
new file mode 100644
index 0000000000..2157259517
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.md
@@ -0,0 +1,38 @@
+# Agent Note: The process manager is its own seam under the bash executors (`dsh-process` / `dsh-process-local`)
+
+Status: implemented
+
+English | [中文](2026-07-26-process-manager-seam.zh.md)
+
+## Problem
+
+`dsh-bash-local` bundled two capabilities that change for different reasons: *running a bash command* (command defaulting, timeout classification, model-friendly terminal environment, the stdout/stderr merge the bash tool renders) and *running and managing a child process* (detached process groups, bounded tail-keep output with spill files, the credential scrub and `DSH_*` merge order, SIGTERM→grace→SIGKILL escalation, kill-and-join disposal). The process half — `run.ts`, roughly half the package — had no seam of its own: a future non-shell runner (a direct-argv executor, a worker supervisor) would have to re-implement or reach into bash internals, and the shared `DSH_*`/`CollectedOutput` vocabulary lived in a package whose name promises shell semantics. The bundling also tied background-process lifetime to the executor's fiber: reloading the bash executor killed every live background process, unlike the sibling [task registry](2026-07-26-task-registry-seam.md), whose registrations deliberately outlive producer fibers.
+
+## Decision
+
+A new `process/` capability family owns "run and manage a process"; the bash family keeps "run a bash command" and consumes it:
+
+- **`@deepseek-ai/dsh-process` (interface)** — the abstract `ProcessManager` owning `ctx.processes` with one method, `spawn(spec): ProcessHandle`, and the shared vocabulary: the fully-explicit `ProcessSpawnSpec` (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), `ProcessHandle` with non-consuming offset-based readers, `ProcessOutcome` with deliberately no timeout/cancel classification, and the `DSH_ENV_PREFIX`/`DshEnvironment`/`CollectedOutput` types. `argv` is never shell-interpreted.
+- **`@deepseek-ai/dsh-process-local` (implementation)** — `LocalProcessManager` 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: ['processes']`; maps each resolved `BashExecSpec` onto a `ProcessSpawnSpec` (`['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-process`, so no bash consumer changes an import; `BashExecRequest`/`BashExecSpec`/`BashProcess` and the sandbox facts remain bash-owned.
+
+Every composition that loads a bash executor now also loads `@deepseek-ai/dsh-process-local` (CLI, examples, python bundled runtime, create-sdk's bash feature resources, inline test configs).
+
+Background-process lifetime moved from the executor to the manager: the executor no longer retains a live-process set, so an executor reload leaves background work running and readable, and composition teardown (the manager's disposal) remains the kill-and-join boundary. One behavioral seam shifted with it: a background spawn failure can no longer be buffered as fake stderr inside the plumbing (the manager rejects `done` and buffers nothing for a process that never ran), so the executor injects the `spawn failed: …` note into exactly one `readOutput()` delta.
+
+## Alternatives considered
+
+**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.processes` 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.
+
+**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.
+
+**Move `ENV_OVERRIDES` (TERM=dumb, PAGER=cat …) into the manager.** Rejected: a generic process manager must not impose terminal presentation policy on non-terminal consumers; the scrub and `DSH_*` channel rules are security/identity invariants and stay, but terminal friendliness is the bash tool's choice, expressed through the ordinary env channel where an explicit caller entry still wins.
+
+## Consequences
+
+Bought: "run and manage a process" is a swappable capability with the standard three-package shape (consumer count starts at two: `bash-local`, `bash-sandbox`); a containerized or remote process backend slots in without touching bash semantics; the shared `DSH_*`/output vocabulary has a non-shell home; and background processes survive executor reloads, matching the task registry's lifetime model. The spawn plumbing suite moved wholesale to `dsh-process-local` (argv-based, plus argv-validation and manager lifecycle/disposal suites); the executor suite now pins the bash-owned layers (classification, merge, spawn-failure note, manager-owned lifetime) against the real manager.
+
+Cost: one more package pair and one more composition row everywhere a bash executor loads — a boot that loads an executor without the manager leaves `ctx.bash` pending on `ctx.processes` (standard missing-service behavior). The moved-vocabulary re-exports keep `dsh-bash` imports working but mean two packages now name the same types; the process seam is the owner and the bash seam documents the re-export. The spawn-failure note became single-delivery through the read path where the old plumbing retained it in the stderr buffer for repeated `readFrom(0)` reads — acceptable because the bash background read path was already a consuming cursor, and the note reaches the one reader that exists.
diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml
index 1d5c37cff5..5521c09b65 100644
--- a/apps/cli/cordis.yml
+++ b/apps/cli/cordis.yml
@@ -88,6 +88,10 @@
- id: workspace
name: '@deepseek-ai/dsh-workspace'
+# Managed child-process groups for the bash executor (spawn/kill/output plumbing).
+- id: processes
+ name: '@deepseek-ai/dsh-process-local'
+
- id: bash-local
name: '@deepseek-ai/dsh-bash-local'
diff --git a/apps/cli/package.json b/apps/cli/package.json
index 4f3bb5be35..7ef006c924 100644
--- a/apps/cli/package.json
+++ b/apps/cli/package.json
@@ -42,6 +42,7 @@
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
+ "@deepseek-ai/dsh-process-local": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
diff --git a/docs/capability-seams.md b/docs/capability-seams.md
index 2da9652c59..0fef36c63e 100644
--- a/docs/capability-seams.md
+++ b/docs/capability-seams.md
@@ -82,10 +82,13 @@ flowchart LR
pkg_agent_spine_demo["agent-spine-demo"]
pkg_goal["goal"]
svc_goals["ctx.goals
Same-session goal domain"]
- pkg_bash["bash"]
- svc_bash["ctx.bash
Bash executor seam"]
+ pkg_process["process"]
+ svc_processes["ctx.processes
Process manager seam"]
+ pkg_process_local["process-local"]
pkg_bash_local["bash-local"]
pkg_bash_sandbox["bash-sandbox"]
+ pkg_bash["bash"]
+ svc_bash["ctx.bash
Bash executor seam"]
svc_bashEnv["ctx.bashEnv
Managed bash environment registry"]
pkg_pty["pty"]
svc_pty["ctx.pty
Persistent PTY session registry"]
@@ -164,6 +167,8 @@ flowchart LR
pkg_modules --> svc_clientModuleHost
pkg_permission --> svc_permission
pkg_plan_mode --> svc_planMode
+ pkg_process --> svc_processes
+ pkg_process_local --> svc_processes
pkg_pty --> svc_pty
pkg_pty_local --> svc_pty
pkg_sandbox --> svc_sandbox
@@ -234,6 +239,8 @@ flowchart LR
svc_invariants --> pkg_session
svc_llm --> pkg_agent_loop
svc_llm --> pkg_compact_basic
+ svc_processes --> pkg_bash_local
+ svc_processes --> pkg_bash_sandbox
svc_pty --> pkg_tool_pty
svc_sandbox --> pkg_bash_sandbox
svc_sandbox --> pkg_pty_local
@@ -317,6 +324,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.processes` | `seam` | [`process`](../packages/process/process) | [`process-local`](../packages/process/process-local) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | - | The bash executors spawn their process groups through ctx.processes; the manager owns group lifetime, bounded spill-backed output, 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 d794425e10..322f5c70d3 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -192,6 +192,8 @@ Source: [`packages/examples/agent-spine-demo/src/index.ts:87`](../packages/examp
## `@deepseek-ai/dsh-bash-local`
+Requires: `processes`
+
```ts config-catalog
/** Plugin config (all optional — `static Config` supplies the defaults). */
export interface Config {
@@ -210,11 +212,11 @@ export interface Config {
}
```
-Source: [`packages/bash/bash-local/src/index.ts:17`](../packages/bash/bash-local/src/index.ts)
+Source: [`packages/bash/bash-local/src/index.ts:39`](../packages/bash/bash-local/src/index.ts)
## `@deepseek-ai/dsh-bash-sandbox`
-Requires: `sandbox` · `sandboxPolicy`
+Requires: `processes` · `sandbox` · `sandboxPolicy`
```ts config-catalog
/**
@@ -2053,6 +2055,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
- `@deepseek-ai/dsh-goal-session` — requires `agents` · `goals` · `sessions` ([`packages/goal/goal-session/src/index.ts`](../packages/goal/goal-session/src/index.ts))
- `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts))
- `@deepseek-ai/dsh-lsp` ([`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts))
+- `@deepseek-ai/dsh-process-local` ([`packages/process/process-local/src/index.ts`](../packages/process/process-local/src/index.ts))
- `@deepseek-ai/dsh-pty` ([`packages/pty/pty/src/index.ts`](../packages/pty/pty/src/index.ts))
- `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts))
- `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session-persistence/session-checkpoint-policy/src/index.ts`](../packages/session-persistence/session-checkpoint-policy/src/index.ts))
@@ -2073,6 +2076,7 @@ Abstract service classes — a deployment loads a concrete implementation packag
- `@deepseek-ai/dsh-code-runtime` — abstract `CodeRuntime` ([`packages/code-runtime/code-runtime/src/index.ts`](../packages/code-runtime/code-runtime/src/index.ts))
- `@deepseek-ai/dsh-compact` — abstract `CompactService` ([`packages/compact/compact/src/index.ts`](../packages/compact/compact/src/index.ts))
- `@deepseek-ai/dsh-fs` — abstract `FileSystem` ([`packages/fs/fs/src/index.ts`](../packages/fs/fs/src/index.ts))
+- `@deepseek-ai/dsh-process` — abstract `ProcessManager` ([`packages/process/process/src/index.ts`](../packages/process/process/src/index.ts))
- `@deepseek-ai/dsh-sandbox` — abstract `SandboxProvider` ([`packages/sandbox/sandbox/src/index.ts`](../packages/sandbox/sandbox/src/index.ts))
- `@deepseek-ai/dsh-session-persistence` — abstract `SessionPersistence` ([`packages/session-persistence/session-persistence/src/index.ts`](../packages/session-persistence/session-persistence/src/index.ts))
- `@deepseek-ai/dsh-session-query` — abstract `SessionQueryService` ([`packages/session-query/session-query/src/index.ts`](../packages/session-query/session-query/src/index.ts))
diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md
index e14d28564a..6ea9919713 100644
--- a/docs/cordis-catalog/services.md
+++ b/docs/cordis-catalog/services.md
@@ -315,7 +315,7 @@ collect(execution: ToolExecution): DshEnvironment
list(): BashEnvVariableInfo[]
```
-Types: [DshEnvironment](../core-data-structures/bash.md) · [ToolExecution](../core-data-structures/tools.md)
+Types: [DshEnvironment](../core-data-structures/process.md) · [ToolExecution](../core-data-structures/tools.md)
Source: [`packages/bash/tool-bash/src/index.ts:104`](../../packages/bash/tool-bash/src/index.ts)
@@ -829,6 +829,31 @@ Types: [Agent](../core-data-structures/core.md)
Source: [`packages/plan/plan-mode/src/index.ts:141`](../../packages/plan/plan-mode/src/index.ts)
+## `ctx.processes` — `ProcessManager` (abstract seam)
+
+Abstract process manager. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.processes` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior).
+
+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.
+- ProcessHandle.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.
+
+```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).
+ */
+abstract spawn(spec: ProcessSpawnSpec): ProcessHandle
+```
+
+Types: [ProcessHandle](../core-data-structures/process.md) · [ProcessSpawnSpec](../core-data-structures/process.md)
+
+Source: [`packages/process/process/src/index.ts:48`](../../packages/process/process/src/index.ts)
+
## `ctx.pty` — `PtyService`
In-process registry for replaceable PTY backends and exact-Agent sessions.
diff --git a/docs/core-data-structures/bash.md b/docs/core-data-structures/bash.md
index 35cf206158..e092bfb1bf 100644
--- a/docs/core-data-structures/bash.md
+++ b/docs/core-data-structures/bash.md
@@ -2,23 +2,13 @@
English | [中文](bash.zh.md)
-The bash execution seam is split across interface ([dsh-bash](../../packages/bash/bash), `ctx.bash`), implementations ([dsh-bash-local](../../packages/bash/bash-local) and [dsh-bash-sandbox](../../packages/bash/bash-sandbox)), and consumer ([dsh-tool-bash](../../packages/bash/tool-bash), the `bash` schema). Generic background-task ids, ownership, and controls live in [tasks.md](tasks.md); this seam returns a task-free process handle.
+The bash execution seam is split across interface ([dsh-bash](../../packages/bash/bash), `ctx.bash`), implementations ([dsh-bash-local](../../packages/bash/bash-local) and [dsh-bash-sandbox](../../packages/bash/bash-sandbox)), and consumer ([dsh-tool-bash](../../packages/bash/tool-bash), the `bash` schema). Generic background-task ids, ownership, and controls live in [tasks.md](tasks.md); this seam returns a task-free process handle. Raw process-group mechanics live behind the [process-manager seam](process.md).
Source: [`packages/bash/bash/src/types.ts`](../../packages/bash/bash/src/types.ts)
## Managed shell environment namespace
-`DSH_*` variables are Harness-owned child-process facts. The model-facing bash tool collects them through `ctx.bashEnv` and passes them through `BashExecRequest.dshEnv`; executors remove inherited `DSH_*` names before merging the current snapshot.
-
-```ts type-equiv
-/** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */
-type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}`
-```
-
-```ts type-equiv
-/** Trusted DeepSeek Harness variables for one bash execution. */
-type DshEnvironment = Readonly>
-```
+`DSH_*` variables are Harness-owned child-process facts. The model-facing bash tool collects them through `ctx.bashEnv` and passes them through `BashExecRequest.dshEnv`; the process manager removes inherited `DSH_*` names before merging the current snapshot. The `DshEnvironmentKey`/`DshEnvironment` vocabulary is owned by the [process-manager seam](process.md) and re-exported by `dsh-bash`.
## Request vs. spec: the `resolve()` split
@@ -145,19 +135,7 @@ interface BashRunResult {
}
```
-Each stream is a `CollectedOutput` — the (possibly truncated) text plus recovery info. When truncated, `text` is the **tail** and the complete stream spills to a private file:
-
-```ts type-equiv
-/** One captured stream: the (possibly truncated) text plus recovery info. */
-interface CollectedOutput {
- /** Collected text — the TAIL of the stream when truncated. */
- text: string
- /** True when bytes were dropped from `text`. */
- truncated: boolean
- /** Path to a file holding the COMPLETE stream, when truncated and available. */
- spillPath?: string
-}
-```
+Each stream is a `CollectedOutput` — the (possibly truncated) text plus recovery info; when truncated, `text` is the **tail** and the complete stream spills to a private file. The shape is owned by the [process-manager seam](process.md) and re-exported by `dsh-bash`.
## File sandbox: `BashSandboxInfo`
diff --git a/docs/core-data-structures/bash.zh.md b/docs/core-data-structures/bash.zh.md
index 0cfeb9e1a8..7ae60b9f38 100644
--- a/docs/core-data-structures/bash.zh.md
+++ b/docs/core-data-structures/bash.zh.md
@@ -2,23 +2,13 @@
[English](bash.md) | 中文
-bash 执行 seam 分为接口([dsh-bash](../../packages/bash/bash),`ctx.bash`)、实现([dsh-bash-local](../../packages/bash/bash-local) 与 [dsh-bash-sandbox](../../packages/bash/bash-sandbox))和消费方([dsh-tool-bash](../../packages/bash/tool-bash),即 `bash` schema)。通用后台任务的 id、所有权与控制位于 [tasks.md](tasks.md);本 seam 返回一个不含任务概念的进程句柄。
+bash 执行 seam 分为接口([dsh-bash](../../packages/bash/bash),`ctx.bash`)、实现([dsh-bash-local](../../packages/bash/bash-local) 与 [dsh-bash-sandbox](../../packages/bash/bash-sandbox))和消费方([dsh-tool-bash](../../packages/bash/tool-bash),即 `bash` schema)。通用后台任务的 id、所有权与控制位于 [tasks.md](tasks.md);本 seam 返回一个不含任务概念的进程句柄。原始进程组机制位于[进程管理器 seam](process.md)之后。
源码:[`packages/bash/bash/src/types.ts`](../../packages/bash/bash/src/types.ts)
## 受管 shell 环境命名空间
-`DSH_*` 变量是归 Harness 所有的子进程事实。面向模型的 bash 工具通过 `ctx.bashEnv` 收集它们,再经由 `BashExecRequest.dshEnv` 传递;执行器在合并当前快照之前会移除继承而来的 `DSH_*` 名称。
-
-```ts type-equiv
-/** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */
-type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}`
-```
-
-```ts type-equiv
-/** Trusted DeepSeek Harness variables for one bash execution. */
-type DshEnvironment = Readonly>
-```
+`DSH_*` 变量是归 Harness 所有的子进程事实。面向模型的 bash 工具通过 `ctx.bashEnv` 收集它们,再经由 `BashExecRequest.dshEnv` 传递;进程管理器在合并当前快照之前会移除继承而来的 `DSH_*` 名称。`DshEnvironmentKey`/`DshEnvironment` 词汇归[进程管理器 seam](process.md)所有,由 `dsh-bash` 重导出。
## 请求与规格:`resolve()` 拆分
@@ -145,19 +135,7 @@ interface BashRunResult {
}
```
-每个流是一个 `CollectedOutput`:(可能被截断的)文本加恢复信息。截断时,`text` 是**尾部**,完整流溢出到一个私有文件:
-
-```ts type-equiv
-/** One captured stream: the (possibly truncated) text plus recovery info. */
-interface CollectedOutput {
- /** Collected text — the TAIL of the stream when truncated. */
- text: string
- /** True when bytes were dropped from `text`. */
- truncated: boolean
- /** Path to a file holding the COMPLETE stream, when truncated and available. */
- spillPath?: string
-}
-```
+每个流是一个 `CollectedOutput`:(可能被截断的)文本加恢复信息;截断时,`text` 是**尾部**,完整流溢出到一个私有文件。该形状归[进程管理器 seam](process.md)所有,由 `dsh-bash` 重导出。
## 文件沙箱:`BashSandboxInfo`
diff --git a/docs/core-data-structures/process.md b/docs/core-data-structures/process.md
new file mode 100644
index 0000000000..93e7a73ad6
--- /dev/null
+++ b/docs/core-data-structures/process.md
@@ -0,0 +1,133 @@
+# Process Manager
+
+The child-process manager seam is split across interface ([dsh-process](../../packages/process/process), `ctx.processes`) and implementation ([dsh-process-local](../../packages/process/process-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.
+
+Source: [`packages/process/process/src/types.ts`](../../packages/process/process/src/types.ts)
+
+## The fully-explicit spawn spec
+
+The seam applies no defaults: every limit and directory is explicit on the spec, so the caller's own config — not a hidden process-manager default — decides them. `argv` is never shell-interpreted.
+
+```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
+ * process-manager default — decides them (the `dsh-bash` request/spec split
+ * is the owning template).
+ */
+interface ProcessSpawnSpec {
+ /** 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. */
+ 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.
+ */
+ 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}.
+ */
+ 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.
+ */
+ dshEnv?: DshEnvironment | undefined
+}
+```
+
+## Handles and offset-based reads
+
+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.
+
+```ts type-equiv
+/**
+ * A live child process. `kill()` starts the group SIGTERM→grace→SIGKILL
+ * escalation; buffered output remains readable after exit.
+ */
+interface ProcessHandle {
+ /** Process id (group leader); -1 when the spawn itself failed. */
+ readonly pid: number
+ /** Live stdout reader (also readable after exit). */
+ readonly stdout: ProcessOutputReader
+ /** Live stderr reader (also readable after exit). */
+ readonly stderr: ProcessOutputReader
+ /** Resolves when the process closes; rejects only for spawn-level failures. */
+ readonly done: Promise
+ /** Begin SIGTERM→grace→SIGKILL on the process group. Idempotent. */
+ kill(): void
+}
+```
+
+```ts type-equiv
+/**
+ * Cursor-free incremental access to one live output stream. Offsets are
+ * whole-stream byte coordinates owned by the caller, so independent readers
+ * cannot consume one another's output.
+ */
+interface ProcessOutputReader {
+ /**
+ * Read everything captured since `fromByte`. When that offset has slid out
+ * of the in-memory tail window the read is `lossy` — it returns the whole
+ * retained tail and the gap is only recoverable from the spill file.
+ * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 for the first read).
+ * @returns the delta text, the next offset, the `lossy` flag, and the spill path when one exists.
+ */
+ readFrom(fromByte: number): ProcessOutputRead
+}
+```
+
+```ts type-equiv
+/** One incremental {@link ProcessOutputReader.readFrom} read. */
+interface ProcessOutputRead {
+ /** Stream text from the requested offset (the whole retained tail when lossy). */
+ text: string
+ /** Whole-stream offset to resume from on the next read. */
+ nextOffset: number
+ /** True when the requested offset slid out of the in-memory tail window. */
+ lossy: boolean
+ /** Path to the full-stream spill file, when one was created and remains intact. */
+ spillPath?: string
+}
+```
+
+## Outcomes carry no cause classification
+
+`done` reports raw exit facts. The manager 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
+/**
+ * Raw outcome of one closed process. Deliberately carries NO timeout or
+ * cancellation classification: the manager kills on abort but does not decide
+ * why — the caller reads the signal it owns to classify causes.
+ */
+interface ProcessOutcome {
+ /** 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 [`ProcessManager`](../../packages/process/process/src/index.ts) seam defines `spawn` only; [`LocalProcessManager`](../../packages/process/process-local/src/index.ts) is the local implementation (detached groups, tail-keep spill-backed collection, credential scrub, kill-and-join disposal). See [`dsh-process`](../../packages/process/process/README.md) for the seam contract and [`dsh-process-local`](../../packages/process/process-local/README.md) for the mechanics.
diff --git a/docs/module-graph.md b/docs/module-graph.md
index 87e0f7c0d5..10e6ab65a6 100644
--- a/docs/module-graph.md
+++ b/docs/module-graph.md
@@ -184,6 +184,10 @@ flowchart TD
subgraph group_mcp["packages/mcp"]
pkg_mcp_client["mcp-client"]
end
+ subgraph group_process["packages/process"]
+ pkg_process["process"]
+ pkg_process_local["process-local"]
+ end
subgraph group_pty["packages/pty"]
pkg_pty["pty"]
pkg_pty_local["pty-local"]
@@ -243,6 +247,7 @@ flowchart TD
pkg_jsonrpc_demo --> pkg_invariants
pkg_host_apiproxy --> pkg_invariants
pkg_host_webserver --> pkg_invariants
+ pkg_process --> pkg_invariants
pkg_storage --> pkg_invariants
pkg_llm --> pkg_brand
pkg_llm --> pkg_invariants
@@ -266,6 +271,8 @@ flowchart TD
pkg_client_ui_workspace --> pkg_client_ui_primitives
pkg_client_ui_workspace --> pkg_client_ui_slots
pkg_client_ui_workspace --> pkg_invariants
+ pkg_process_local --> pkg_invariants
+ pkg_process_local --> pkg_process
pkg_helper --> pkg_brand
pkg_helper --> pkg_invariants
pkg_telemetry --> pkg_brand
@@ -307,6 +314,7 @@ flowchart TD
pkg_agent --> pkg_session
pkg_agent --> pkg_system_prompt
pkg_bash --> pkg_invariants
+ pkg_bash --> pkg_process
pkg_bash --> pkg_sandbox
pkg_fs --> pkg_brand
pkg_fs --> pkg_invariants
@@ -371,6 +379,7 @@ flowchart TD
pkg_goal --> pkg_session
pkg_bash_local --> pkg_bash
pkg_bash_local --> pkg_invariants
+ pkg_bash_local --> pkg_process
pkg_bash_local --> pkg_timeout
pkg_fs_local --> pkg_fs
pkg_fs_local --> pkg_invariants
@@ -828,6 +837,7 @@ flowchart TD
| [`jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | [`invariants`](../packages/support/invariants) |
| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/support/invariants) |
| [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) |
+| [`process`](../packages/process/process) | `process` | [`invariants`](../packages/support/invariants) |
| [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) |
| [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) |
| [`client-connection`](../packages/client/connection) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) |
@@ -836,6 +846,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) |
+| [`process-local`](../packages/process/process-local) | `process` | [`invariants`](../packages/support/invariants), [`process`](../packages/process/process) |
| [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) |
| [`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) |
@@ -850,7 +861,7 @@ flowchart TD
| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
| [`token-meter`](../packages/llm/token-meter) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`agent`](../packages/core/agent) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
-| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox) |
+| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`process`](../packages/process/process), [`sandbox`](../packages/sandbox/sandbox) |
| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
| [`compact`](../packages/compact/compact) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
@@ -869,7 +880,7 @@ flowchart TD
| [`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) |
| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session) |
-| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) |
+| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`process`](../packages/process/process), [`timeout`](../packages/util/timeout) |
| [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) |
| [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) |
| [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`skill`](../packages/skill/skill) |
diff --git a/examples/acp-agent/composition.md b/examples/acp-agent/composition.md
index 8d112f2910..e4660ed8da 100644
--- a/examples/acp-agent/composition.md
+++ b/examples/acp-agent/composition.md
@@ -14,6 +14,8 @@ flowchart LR
cfg --> plugin_acp_sandbox
plugin_acp_sandbox_policy["sandbox-policy
@deepseek-ai/dsh-sandbox-policy"]
cfg --> plugin_acp_sandbox_policy
+ plugin_acp_processes["processes
@deepseek-ai/dsh-process-local"]
+ cfg --> plugin_acp_processes
plugin_acp_bash["bash
@deepseek-ai/dsh-bash-sandbox"]
cfg --> plugin_acp_bash
plugin_acp_approval["approval
@deepseek-ai/dsh-user-approval"]
@@ -68,6 +70,7 @@ flowchart LR
| `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` |
| `sandbox` | `@deepseek-ai/dsh-sandbox-local` |
| `sandbox-policy` | `@deepseek-ai/dsh-sandbox-policy` |
+| `processes` | `@deepseek-ai/dsh-process-local` |
| `bash` | `@deepseek-ai/dsh-bash-sandbox` |
| `approval` | `@deepseek-ai/dsh-user-approval` |
| `acp-agent` | `@deepseek-ai/dsh-acp-demo` |
diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml
index fa7f6ad4ef..38994b1826 100644
--- a/examples/acp-agent/cordis.yml
+++ b/examples/acp-agent/cordis.yml
@@ -33,6 +33,10 @@
mode: !!js "process.env.DSH_PERMISSION_MODE ?? (process.env.DSH_SNAPSHOT === undefined ? 'workspace-write' : 'danger-full-access')"
workspaceRoot: !!js process.cwd()
+# Managed child-process groups for the bash executor (spawn/kill/output plumbing).
+- id: processes
+ name: '@deepseek-ai/dsh-process-local'
+
- id: bash
name: '@deepseek-ai/dsh-bash-sandbox'
config:
diff --git a/examples/cordis-agent/composition.md b/examples/cordis-agent/composition.md
index 3cbcb0e571..78eb186e37 100644
--- a/examples/cordis-agent/composition.md
+++ b/examples/cordis-agent/composition.md
@@ -12,6 +12,8 @@ flowchart LR
cfg --> plugin_cordis_hmr
plugin_cordis_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"]
cfg --> plugin_cordis_llm_deepseek
+ plugin_cordis_processes["processes
@deepseek-ai/dsh-process-local"]
+ cfg --> plugin_cordis_processes
plugin_cordis_bash["bash
@deepseek-ai/dsh-bash-local"]
cfg --> plugin_cordis_bash
plugin_cordis_fs_local["fs-local
@deepseek-ai/dsh-fs-local"]
@@ -39,6 +41,7 @@ flowchart LR
| --- | --- |
| `hmr` | `@cordisjs/plugin-hmr` |
| `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` |
+| `processes` | `@deepseek-ai/dsh-process-local` |
| `bash` | `@deepseek-ai/dsh-bash-local` |
| `fs-local` | `@deepseek-ai/dsh-fs-local` |
| `web` | `@deepseek-ai/dsh-web` |
diff --git a/examples/cordis-agent/cordis.yml b/examples/cordis-agent/cordis.yml
index 6144d3ae4d..051c3a5662 100644
--- a/examples/cordis-agent/cordis.yml
+++ b/examples/cordis-agent/cordis.yml
@@ -25,6 +25,10 @@
# Local bash executor for agent-spine-demo's tool-bash schema — gives the agent an
# ordinary tool whose calls make the mounted listeners observably fire.
+# Managed child-process groups for the bash executor (spawn/kill/output plumbing).
+- id: processes
+ name: '@deepseek-ai/dsh-process-local'
+
- id: bash
name: '@deepseek-ai/dsh-bash-local'
config:
diff --git a/examples/headless-agent/composition.md b/examples/headless-agent/composition.md
index e467d32833..98ef6cd167 100644
--- a/examples/headless-agent/composition.md
+++ b/examples/headless-agent/composition.md
@@ -10,6 +10,8 @@ flowchart LR
cfg["examples/headless-agent
cordis.yml"]
plugin_headless_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"]
cfg --> plugin_headless_llm_deepseek
+ plugin_headless_processes["processes
@deepseek-ai/dsh-process-local"]
+ cfg --> plugin_headless_processes
plugin_headless_bash["bash
@deepseek-ai/dsh-bash-local"]
cfg --> plugin_headless_bash
plugin_headless_cli_agent["cli-agent
@deepseek-ai/dsh-cli-demo"]
@@ -54,6 +56,7 @@ flowchart LR
| Plugin id | Package / module |
| --- | --- |
| `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` |
+| `processes` | `@deepseek-ai/dsh-process-local` |
| `bash` | `@deepseek-ai/dsh-bash-local` |
| `cli-agent` | `@deepseek-ai/dsh-cli-demo` |
| `token-meter` | `@deepseek-ai/dsh-token-meter` |
diff --git a/examples/headless-agent/cordis.yml b/examples/headless-agent/cordis.yml
index 944fe7e180..261eaa6d20 100644
--- a/examples/headless-agent/cordis.yml
+++ b/examples/headless-agent/cordis.yml
@@ -19,6 +19,10 @@
- id: deepseek-v4-flash
contextWindow: 128000
+# Managed child-process groups for the bash executor (spawn/kill/output plumbing).
+- id: processes
+ name: '@deepseek-ai/dsh-process-local'
+
- id: bash
name: '@deepseek-ai/dsh-bash-local'
config:
diff --git a/examples/headless-agent/semantic-checkpoint.cordis.snapshot.yml b/examples/headless-agent/semantic-checkpoint.cordis.snapshot.yml
index fc7959e25d..66fbca2e6a 100644
--- a/examples/headless-agent/semantic-checkpoint.cordis.snapshot.yml
+++ b/examples/headless-agent/semantic-checkpoint.cordis.snapshot.yml
@@ -17,6 +17,10 @@
file: !!js process.env.DSH_SNAPSHOT_FILE
overrideFile: !!js process.env.DSH_SNAPSHOT_OVERRIDE
+# Managed child-process groups for the bash executor (spawn/kill/output plumbing).
+- id: processes
+ name: '@deepseek-ai/dsh-process-local'
+
- id: bash
name: '@deepseek-ai/dsh-bash-local'
config:
diff --git a/examples/headless-agent/tests/code-mode.e2e.ts b/examples/headless-agent/tests/code-mode.e2e.ts
index bb8fdfe260..6f36b1f8c9 100644
--- a/examples/headless-agent/tests/code-mode.e2e.ts
+++ b/examples/headless-agent/tests/code-mode.e2e.ts
@@ -13,6 +13,7 @@ import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
+import LocalProcessManager from '@deepseek-ai/dsh-process-local'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import { WorkerCodeRuntime } from '@deepseek-ai/dsh-code-runtime-worker'
@@ -55,6 +56,7 @@ async function codeModeHarness(cwd: string): Promise {
await harness.plugin(AgentRegistry)
await harness.plugin(AgentLoop, { agents: [] })
await harness.plugin(LlmDeepSeek)
+ await harness.plugin(LocalProcessManager)
await harness.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 })
await harness.plugin(ToolBash)
await harness.plugin(WorkerCodeRuntime, {})
@@ -114,6 +116,7 @@ async function backgroundCodeModeHarness(cwd: string): Promise {
const harness = await typedCodeModeHarness()
await harness.plugin(LocalTaskService)
await harness.plugin(ToolTasks, {})
+ await harness.plugin(LocalProcessManager)
await harness.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 })
await harness.plugin(ToolBash)
return harness
diff --git a/examples/headless-agent/tests/fixtures/goal-domain/cordis.yml b/examples/headless-agent/tests/fixtures/goal-domain/cordis.yml
index bc9b71685e..d20c05b785 100644
--- a/examples/headless-agent/tests/fixtures/goal-domain/cordis.yml
+++ b/examples/headless-agent/tests/fixtures/goal-domain/cordis.yml
@@ -2,6 +2,10 @@
- id: cli-mock-llm
name: '../cli-mock-llm.ts'
+# Managed child-process groups for the bash executor (spawn/kill/output plumbing).
+- id: processes
+ name: '@deepseek-ai/dsh-process-local'
+
- id: bash
name: '@deepseek-ai/dsh-bash-local'
diff --git a/examples/headless-agent/tests/fixtures/time-context.cordis.yml b/examples/headless-agent/tests/fixtures/time-context.cordis.yml
index 91ba8a1254..1050d32376 100644
--- a/examples/headless-agent/tests/fixtures/time-context.cordis.yml
+++ b/examples/headless-agent/tests/fixtures/time-context.cordis.yml
@@ -2,6 +2,10 @@
- id: time-context-mock-llm
name: './time-context-mock-llm.ts'
+# Managed child-process groups for the bash executor (spawn/kill/output plumbing).
+- id: processes
+ name: '@deepseek-ai/dsh-process-local'
+
- id: bash
name: '@deepseek-ai/dsh-bash-local'
diff --git a/examples/headless-agent/tests/harness.ts b/examples/headless-agent/tests/harness.ts
index e1edc1dadd..cf5f30aaee 100644
--- a/examples/headless-agent/tests/harness.ts
+++ b/examples/headless-agent/tests/harness.ts
@@ -4,6 +4,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
+import LocalProcessManager from '@deepseek-ai/dsh-process-local'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
@@ -59,6 +60,7 @@ export async function codingHarness(workdir: string, options: CodingHarnessOptio
await ctx.plugin(LlmDeepSeek, options.modelContextWindow === undefined ? {} : {
models: [{ id: 'deepseek-v4-flash', contextWindow: options.modelContextWindow }],
})
+ await ctx.plugin(LocalProcessManager)
await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 })
await ctx.plugin(ToolBash)
await ctx.plugin(ToolTodo)
diff --git a/examples/jsonrpc-agent/cordis.yml b/examples/jsonrpc-agent/cordis.yml
index f0eb7ea6d8..7576b06cbf 100644
--- a/examples/jsonrpc-agent/cordis.yml
+++ b/examples/jsonrpc-agent/cordis.yml
@@ -17,6 +17,10 @@
thinking: enabled
reasoningEffort: max
+# Managed child-process groups for the bash executor (spawn/kill/output plumbing).
+- id: processes
+ name: '@deepseek-ai/dsh-process-local'
+
- id: bash
name: '@deepseek-ai/dsh-bash-local'
config:
diff --git a/examples/package.json b/examples/package.json
index 8a81d399b8..183aa03111 100644
--- a/examples/package.json
+++ b/examples/package.json
@@ -31,6 +31,7 @@
"@deepseek-ai/dsh-lsp": "workspace:*",
"@deepseek-ai/dsh-lsp-local": "workspace:*",
"@deepseek-ai/dsh-plan-mode": "workspace:*",
+ "@deepseek-ai/dsh-process-local": "workspace:*",
"@deepseek-ai/dsh-permission": "workspace:*",
"@deepseek-ai/dsh-pty": "workspace:*",
"@deepseek-ai/dsh-pty-local": "workspace:*",
diff --git a/examples/tui-agent/composition.md b/examples/tui-agent/composition.md
index fd6d163952..9c64a95fbb 100644
--- a/examples/tui-agent/composition.md
+++ b/examples/tui-agent/composition.md
@@ -12,6 +12,8 @@ flowchart LR
cfg --> plugin_tui_hmr
plugin_tui_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"]
cfg --> plugin_tui_llm_deepseek
+ plugin_tui_processes["processes
@deepseek-ai/dsh-process-local"]
+ cfg --> plugin_tui_processes
plugin_tui_bash["bash
@deepseek-ai/dsh-bash-local"]
cfg --> plugin_tui_bash
plugin_tui_tui_agent["tui-agent
@deepseek-ai/dsh-tui-demo"]
@@ -69,6 +71,7 @@ flowchart LR
| --- | --- |
| `hmr` | `@cordisjs/plugin-hmr` |
| `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` |
+| `processes` | `@deepseek-ai/dsh-process-local` |
| `bash` | `@deepseek-ai/dsh-bash-local` |
| `tui-agent` | `@deepseek-ai/dsh-tui-demo` |
| `session-title-llm` | `@deepseek-ai/dsh-session-title-first-message-llm` |
diff --git a/examples/tui-agent/cordis.yml b/examples/tui-agent/cordis.yml
index e96ef680b2..9a67ce4f77 100644
--- a/examples/tui-agent/cordis.yml
+++ b/examples/tui-agent/cordis.yml
@@ -21,6 +21,10 @@
reasoningEffort: max
# Local executor for the app bundle's bash tool.
+# Managed child-process groups for the bash executor (spawn/kill/output plumbing).
+- id: processes
+ name: '@deepseek-ai/dsh-process-local'
+
- id: bash
name: '@deepseek-ai/dsh-bash-local'
config:
diff --git a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml
index 307c5a1dfe..f25cafc64b 100644
--- a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml
+++ b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml
@@ -4,6 +4,10 @@
- id: scripted-llm
name: './tui-scripted-llm.ts'
+# Managed child-process groups for the bash executor (spawn/kill/output plumbing).
+- id: processes
+ name: '@deepseek-ai/dsh-process-local'
+
- id: bash
name: '@deepseek-ai/dsh-bash-local'
diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts
index 26ba64f23b..795f21a794 100644
--- a/examples/tui-agent/tests/tui.snapshot.ts
+++ b/examples/tui-agent/tests/tui.snapshot.ts
@@ -8,6 +8,7 @@ import { scrubRequestHeaders } from '@deepseek-ai/dsh-acp-snapshot'
import type { Agent } from '@deepseek-ai/dsh-agent'
import * as AgentCore from '@deepseek-ai/dsh-agent-spine-demo'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
+import LocalProcessManager from '@deepseek-ai/dsh-process-local'
import WorkerCodeRuntime from '@deepseek-ai/dsh-code-runtime-worker'
import CommandService from '@deepseek-ai/dsh-commands'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
@@ -204,6 +205,7 @@ async function mountScenarioContext(
skills: { local: { agentsHome: join(cwd, '.agents') } },
})
await ctx.plugin(TokenMeterService)
+ await ctx.plugin(LocalProcessManager)
await ctx.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 })
await ctx.plugin(SnapshotLocalFileSystem, { cwd: '/' })
await ctx.plugin(FsPolicy)
diff --git a/packages/README.md b/packages/README.md
index cffc4a0554..f9707a178d 100644
--- a/packages/README.md
+++ b/packages/README.md
@@ -11,6 +11,7 @@ Packages live at `packages///`; groups are containers, while names r
| [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | Product — stable surface |
| [`goal/`](goal/README.md) | Persisted same-session goal state and lifecycle | Product — stable surface |
| [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface |
+| [`process/`](process/README.md) | Child-process manager capability family: spawn seam + local process-group implementation | Product — stable surface |
| [`bash/`](bash/README.md) | Bash capability family: executor seam, local impl, model-facing tool | Product — stable surface |
| [`pty/`](pty/README.md) | Persistent PTY capability family: owner-scoped sessions, local implementation, and model-facing tools | Product — stable surface |
| [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the runtime seam for model-written programs + a worker-thread backend | Product — stable surface |
diff --git a/packages/bash/README.md b/packages/bash/README.md
index 2e2bb5692a..57b0ffcf9e 100644
--- a/packages/bash/README.md
+++ b/packages/bash/README.md
@@ -4,8 +4,8 @@ The canonical three-package capability seam (see [capability seams](../../.agent
| Package | Role | ctx key |
|---|---|---|
-| `bash/` | Abstract bash executor seam (interface + vocabulary; sandbox result facts carry the [`sandbox/`](../sandbox/README.md) seam's mode/enforcement vocabulary) | `ctx.bash` |
-| `bash-local/` | Local-subprocess `BashExecutor` implementation | (registers `ctx.bash`) |
+| `bash/` | Abstract bash executor seam (interface + vocabulary; sandbox result facts carry the [`sandbox/`](../sandbox/README.md) seam's mode/enforcement vocabulary, and the managed-env/output vocabulary is re-exported from the [`process/`](../process/README.md) seam) | `ctx.bash` |
+| `bash-local/` | Local `BashExecutor` implementation over the [`process/`](../process/README.md) manager (command defaulting, deadlines, terminal env, background-read merge) | (registers `ctx.bash`) |
| `bash-sandbox/` | Sandbox-consuming `BashExecutor` (wraps every command argv via `ctx.sandbox`, stamps denial/enforcement facts; extends `bash-local`'s mechanics) | (registers `ctx.bash`) |
| `tool-bash/` | Model-facing `bash` schema; background processes register with the generic [`tasks/`](../tasks/README.md) runtime | (registers on `ctx.tools`) |
diff --git a/packages/bash/bash-local/README.md b/packages/bash/bash-local/README.md
index 5d99161fc0..5fb4bd0a39 100644
--- a/packages/bash/bash-local/README.md
+++ b/packages/bash/bash-local/README.md
@@ -1,8 +1,8 @@
# @deepseek-ai/dsh-bash-local
-Local-subprocess implementation of the `@deepseek-ai/dsh-bash` executor seam: `LocalBashExecutor` spawns `bash -c ` per call in its own process group, collects bounded output with size-limited full-stream spill files, and escalates kills SIGTERM→SIGKILL across the whole group.
+Local implementation of the `@deepseek-ai/dsh-bash` executor seam over the [`@deepseek-ai/dsh-process`](../../process/process/README.md) manager: `LocalBashExecutor` spawns `bash -c ` per call as a managed process group through `ctx.processes`, and owns everything bash-shaped — command defaulting and caps, timeout/cancel classification, the model-friendly terminal environment, and the model-facing stdout/stderr merge for background reads. Group mechanics (bounded spill-backed output, credential scrub, kill escalation, disposal) are the process manager's.
-The package root exports the default and named `LocalBashExecutor` plugin plus its `Config`; subprocess plumbing stays internal to the implementation package.
+The package root exports the default and named `LocalBashExecutor` plugin plus its `Config`.
## Config
@@ -22,11 +22,11 @@ The package root exports the default and named `LocalBashExecutor` plugin plus i
Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi; the notable choices:
-- **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` (deterministic; no rc files). All four surveyed tools spawn per call. `XXX(stateful-shell)` in `src/run.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them.
-- **Process-group kills with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after the `graceMs` grace (default 3s — OpenCode's escalation; pipelines and subshells die with the parent). After the main shell exits, inherited stdout/stderr pipes receive the same bounded drain grace so a surviving descendant cannot hold the command open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — same caveat as the surveyed tools.
-- **Tail-keep truncation + bounded spill files** — output beyond `maxOutputBytes` keeps the in-memory TAIL (errors/results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a temp file whose path is reported when available. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background tasks still use `maxOutputBytes`. A stream larger than `maxSpillBytes` discards its now-incomplete spill and returns only the marked truncated tail. If the final spill close reports a delayed writeback failure, the executor likewise withholds the path rather than advertising an incomplete file.
-- **Model-friendly env + credential scrub** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names, then `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results. A spec's ordinary `env` is merged 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).
-- **Background processes** — `start()` returns a live `BashProcess` handle immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), the handle's `readOutput()` is incremental with whole-stream byte offsets, and disposal kills every running process and awaits its exit. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry.
+- **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` (deterministic; no rc files). All four surveyed tools spawn per call. `XXX(stateful-shell)` in `src/index.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them.
+- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the manager explicit byte caps, spill cap, and `graceMs` (default 3s — OpenCode's escalation). Process-group kills, the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-process-local`](../../process/process-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`.
+- **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-signaled command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)).
+- **Model-friendly terminal env** — `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results, merged as ordinary env under the manager's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. 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).
+- **Background processes** — `start()` returns a live `BashProcess` handle immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), and the handle's `readOutput()` merges the manager's offset-based stdout/stderr reads into one marked-section delta with a consuming cursor. A still-running process belongs to the manager, so it survives executor reloads and dies (killed and joined) with the manager's disposal. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry.
## Model Experience
@@ -40,8 +40,7 @@ No direct invalidation; the named consumer owns any request-prefix changes.
- **Unconfined by itself** — this executor always runs commands with the harness process's authority; deployments needing confinement compose [`dsh-bash-sandbox`](../bash-sandbox/README.md), while per-call allow/deny/ask policy belongs on `tools/pre-execute`.
- **No persistent shell or PTY** — every call starts a fresh non-login `bash -c`; cwd-only persistence and interactive terminal sessions remain deferred until a real workflow requires them.
-- **POSIX-only** — the `bash` binary, detached process groups, group kills, and SIGTERM→SIGKILL escalation are hardcoded; Windows is unsupported.
-- **The credential scrub is a name heuristic** — `*KEY*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSWORD*`) pass through, and a whitelist for over-scrubbed vars is noted future work.
-- **Completed spill files are not deleted** — bounded full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them; oversize incomplete spills are discarded and deletion is attempted immediately, but a cleanup failure can leave a bounded file behind.
+- **POSIX-only** — the `bash` binary is hardcoded, and the underlying manager's group semantics are POSIX; Windows is unsupported.
+- **A background spawn-failure note is single-delivery** — the manager buffers no output for a process that never ran, so the executor injects `spawn failed: …` into exactly one `readOutput()` delta; a reader that discards that delta cannot recover it.
-The raw process handling lives in `src/run.ts`; `src/index.ts` is the service wiring.
+Scrub-heuristic and spill-retention caveats live with [`dsh-process-local`](../../process/process-local/README.md), which owns those mechanics.
diff --git a/packages/bash/bash-local/package.json b/packages/bash/bash-local/package.json
index 152c2db28b..edfcafabe5 100644
--- a/packages/bash/bash-local/package.json
+++ b/packages/bash/bash-local/package.json
@@ -29,6 +29,7 @@
"peerDependencies": {
"@deepseek-ai/dsh-bash": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
+ "@deepseek-ai/dsh-process": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
@@ -38,6 +39,8 @@
"devDependencies": {
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
+ "@deepseek-ai/dsh-process": "workspace:^",
+ "@deepseek-ai/dsh-process-local": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts
index 2c25701fb9..5cb99b3caa 100644
--- a/packages/bash/bash-local/src/index.ts
+++ b/packages/bash/bash-local/src/index.ts
@@ -1,7 +1,10 @@
/**
- * Local-subprocess implementation of the bash executor seam. Each command runs
- * as `bash -c` in its own process group; disposal kills and joins live groups.
- * Execution policy belongs in `tools/pre-execute` or a sandboxing executor.
+ * Local implementation of the bash executor seam over the process-manager
+ * seam. Each command runs as `bash -c` in a managed process group spawned
+ * through `ctx.processes`; this executor owns command defaulting, deadlines
+ * and cause classification, the model-friendly terminal environment, and the
+ * model-facing stdout/stderr merge for background reads. Execution policy
+ * belongs in `tools/pre-execute` or a sandboxing executor.
* @module @deepseek-ai/dsh-bash-local
*/
@@ -9,9 +12,28 @@ import { Context } from 'cordis'
import z from 'schemastery'
import { BashExecutor } from '@deepseek-ai/dsh-bash'
import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult } from '@deepseek-ai/dsh-bash'
+import type { ProcessSpawnSpec } from '@deepseek-ai/dsh-process'
import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
-import { DEFAULT_GRACE_MS, DEFAULT_MAX_SPILL_BYTES, runBash } from './run.ts'
-import type { RunInternals, RunningBash } from './run.ts'
+
+/**
+ * Model-friendly environment overrides: disable colors, pagers, and
+ * interactive terminal features that would garble tool output (the same set
+ * Codex hardcodes; Claude Code achieves it via TERM=dumb). Bash-tool policy —
+ * merged into the ordinary env channel, so a trusted caller's own entry still
+ * wins; the process manager applies its credential scrub independently.
+ */
+export const ENV_OVERRIDES = {
+ NO_COLOR: '1',
+ TERM: 'dumb',
+ PAGER: 'cat',
+ GIT_PAGER: 'cat',
+} as const
+
+/** Default SIGTERM→SIGKILL grace period (the `graceMs` config; matches OpenCode's 3s). */
+const DEFAULT_GRACE_MS = 3_000
+
+/** Default per-stream spill cap (the `maxSpillBytes` config). */
+const DEFAULT_MAX_SPILL_BYTES = 64 * 1024 * 1024
/** Plugin config (all optional — `static Config` supplies the defaults). */
export interface Config {
@@ -39,10 +61,15 @@ function assertPositiveFinite(name: string, value: number): void {
}
/**
- * Local bash executor with bounded output, spill files, and process-group
- * `SIGTERM` to `SIGKILL` escalation.
+ * Local bash executor over `ctx.processes`. Bounded output, spill files, and
+ * process-group SIGTERM→SIGKILL escalation are the process manager's
+ * mechanics; this executor supplies their configured budgets per spawn, so a
+ * still-running background process stays managed (killed and joined at
+ * composition teardown) even across an executor reload.
*/
export class LocalBashExecutor extends BashExecutor {
+ static inject = ['processes']
+
static Config: z = z.object({
cwd: z.string(),
timeoutMs: z.number().default(120_000),
@@ -52,11 +79,6 @@ export class LocalBashExecutor extends BashExecutor {
graceMs: z.number().default(DEFAULT_GRACE_MS),
})
- /** Live processes retained only so disposal can kill and join them. */
- private live = new Map()
- /** Test seam: spill knobs forwarded to runBash. */
- internals: RunInternals = {}
-
/** Validated config (schemastery applied the defaults before construction). */
readonly config: ResolvedConfig
@@ -69,17 +91,6 @@ export class LocalBashExecutor extends BashExecutor {
assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes)
assertPositiveFinite('maxSpillBytes', this.config.maxSpillBytes)
assertPositiveFinite('graceMs', this.config.graceMs)
- ctx.effect(() => async () => {
- // Await closure so even a TERM-trapping child cannot outlive the fiber.
- const pending: Promise[] = []
- for (const [proc, running] of this.live) {
- proc.status = 'killed'
- running.kill()
- pending.push(proc.done)
- }
- this.live.clear()
- await Promise.all(pending)
- }, 'local bash teardown')
}
/**
@@ -105,7 +116,7 @@ export class LocalBashExecutor extends BashExecutor {
stdoutMaxBytes,
...request.signal ? { signal: request.signal } : {},
// Carry stdin/ordinary env/trusted dshEnv through verbatim — optional,
- // no config default. run.ts owns the scrub and merge order.
+ // no config default. The process manager owns the scrub and merge order.
...request.stdin !== undefined ? { stdin: request.stdin } : {},
...request.env !== undefined ? { env: request.env } : {},
...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {},
@@ -116,21 +127,27 @@ export class LocalBashExecutor extends BashExecutor {
}
}
- async run(spec: BashExecSpec): Promise {
- // One deadline combines timeout and upstream cancellation; disposal clears its timer.
- using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT')
- const outcome = await runBash({
- command: spec.command,
+ /** Map one resolved bash spec onto a fully-specified process spawn. */
+ // XXX(stateful-shell): evaluate persistent cwd or PTY sessions when workflows require shell state.
+ private spawnSpec(spec: BashExecSpec, stdoutMaxBytes: number, signal: AbortSignal | undefined): ProcessSpawnSpec {
+ return {
+ argv: ['bash', '-c', spec.command],
cwd: spec.workdir,
- stdoutMaxBytes: spec.stdoutMaxBytes,
+ stdoutMaxBytes,
stderrMaxBytes: this.config.maxOutputBytes,
maxSpillBytes: this.config.maxSpillBytes,
graceMs: this.config.graceMs,
- signal: d.signal,
+ signal,
stdin: spec.stdin,
- env: spec.env,
+ env: { ...ENV_OVERRIDES, ...spec.env },
dshEnv: spec.dshEnv,
- }, this.internals).done
+ }
+ }
+
+ async run(spec: BashExecSpec): Promise {
+ // One deadline combines timeout and upstream cancellation; disposal clears its timer.
+ using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT')
+ const outcome = await this.ctx.processes.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal)).done
// Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts.
const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined
const aborted = d.signal.aborted && !timedOut
@@ -139,18 +156,16 @@ export class LocalBashExecutor extends BashExecutor {
start(spec: BashExecSpec): BashProcess {
// Background runs ignore timeoutMs; callers stop them through kill() or spec.signal.
- const running = runBash({
- command: spec.command,
- cwd: spec.workdir,
- stdoutMaxBytes: this.config.maxOutputBytes,
- stderrMaxBytes: this.config.maxOutputBytes,
- maxSpillBytes: this.config.maxSpillBytes,
- graceMs: this.config.graceMs,
- signal: spec.signal,
- stdin: spec.stdin,
- env: spec.env,
- dshEnv: spec.dshEnv,
- }, this.internals)
+ const running = this.ctx.processes.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal))
+
+ // A spawn failure produces no process output, so the manager has nothing
+ // to buffer; the note is delivered exactly once through the read path.
+ let spawnFailureNote: string | undefined
+ const consumeSpawnFailure = (): string => {
+ const note = spawnFailureNote ?? ''
+ spawnFailureNote = undefined
+ return note
+ }
let stdoutOffset = 0
let stderrOffset = 0
@@ -166,13 +181,11 @@ export class LocalBashExecutor extends BashExecutor {
proc.exitCode = outcome.exitCode
proc.signal = outcome.signal
this.onProcessDone(proc, running.stderr.readFrom(0).text)
- this.live.delete(proc)
}, (error: unknown) => {
// Background spawn failures settle as killed and surface through the read path.
proc.status = 'killed'
- running.stderr.push(Buffer.from(`spawn failed: ${String(error)}`))
- this.onProcessDone(proc, running.stderr.readFrom(0).text)
- this.live.delete(proc)
+ spawnFailureNote = `spawn failed: ${String(error)}`
+ this.onProcessDone(proc, spawnFailureNote)
}),
readOutput: (): BashProcessRead => {
const out = running.stdout.readFrom(stdoutOffset)
@@ -180,11 +193,14 @@ export class LocalBashExecutor extends BashExecutor {
stdoutOffset = out.nextOffset
stderrOffset = err.nextOffset
+ // A failed spawn never produced process output, so the note and real
+ // stderr text are mutually exclusive.
+ const errText = err.text.length > 0 ? err.text : consumeSpawnFailure()
// Single newline between sections: stdout chunks usually end with one
// already; add it only when missing.
const separator = out.text.length > 0 && !out.text.endsWith('\n') ? '\n' : ''
const delta = out.text
- + (err.text.length > 0 ? `${separator}[stderr]\n${err.text}` : '')
+ + (errText.length > 0 ? `${separator}[stderr]\n${errText}` : '')
return {
delta,
lossy: out.lossy || err.lossy,
@@ -199,7 +215,6 @@ export class LocalBashExecutor extends BashExecutor {
return true
},
}
- this.live.set(proc, running)
return proc
}
diff --git a/packages/bash/bash-local/tests/executor.spec.ts b/packages/bash/bash-local/tests/executor.spec.ts
index 2e24addb3b..aa5eb2fb92 100644
--- a/packages/bash/bash-local/tests/executor.spec.ts
+++ b/packages/bash/bash-local/tests/executor.spec.ts
@@ -4,16 +4,18 @@ import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
+import LocalProcessManager from '@deepseek-ai/dsh-process-local'
import type { BashProcess } from '@deepseek-ai/dsh-bash'
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-exec-spec-'))
async function setup(config: ConstructorParameters[1] = {}) {
const ctx = new Context()
+ await ctx.plugin(LocalProcessManager)
+ ;(ctx.processes as LocalProcessManager).internals = { spillDir }
// A short kill grace via the REAL config path, so escalation tests stay fast.
await ctx.plugin(LocalBashExecutor, { graceMs: 200, ...config })
const bash = ctx.bash as LocalBashExecutor
- bash.internals = { spillDir }
return { ctx, bash }
}
@@ -293,44 +295,52 @@ describe('LocalBashExecutor.start (background process handles)', () => {
})
})
-describe('LocalBashExecutor disposal', () => {
- it('disposing the fiber kills running processes and AWAITS their exit (no orphans, SIGKILL escalation included)', async () => {
+describe('process lifecycle ownership (the manager, not the executor)', () => {
+ it('a background process survives executor-fiber disposal and dies with the process manager', async () => {
const ctx = new Context()
- const fiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 })
+ const managerFiber = await ctx.plugin(LocalProcessManager)
+ ;(ctx.processes as LocalProcessManager).internals = { spillDir }
+ const executorFiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 })
const bash = ctx.bash as LocalBashExecutor
- bash.internals = { spillDir }
// The child prints its own pid ($$ = the detached bash group leader) so
// the test can probe liveness through the public read surface alone.
- const proc = bash.start(bash.resolve({ command: 'trap \'\' TERM; echo $$; sleep 60' }))
+ const proc = bash.start(bash.resolve({ command: 'echo $$; sleep 60' }))
const pid = Number((await readUntil(proc, '\n')).trim())
expect(Number.isInteger(pid) && pid > 0).toBe(true)
- await fiber.dispose()
- // Disposal itself waited: the pid must already be gone, no grace left —
- // even for a TERM-trapping child held until the SIGKILL escalation landed.
+ // Reloading/disposing the executor no longer kills backend work — the
+ // handle stays live and readable, mirroring the task runtime's
+ // registrations-outlive-producer-fibers contract.
+ await executorFiber.dispose()
+ expect(proc.status).toBe('running')
+ expect(() => process.kill(pid, 0)).not.toThrow()
+
+ // Manager disposal kills the group and AWAITS its exit (no orphans).
+ await managerFiber.dispose()
expect(() => process.kill(pid, 0)).toThrow()
- expect(proc.status).toBe('killed')
await proc.done
+ expect(proc.status).toBe('killed')
})
- it('settled processes already left the live map: dispose does not touch them', async () => {
+ it('manager disposal escalates to SIGKILL for TERM-trapping children and settles handles', async () => {
const ctx = new Context()
- const fiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 })
+ const managerFiber = await ctx.plugin(LocalProcessManager)
+ ;(ctx.processes as LocalProcessManager).internals = { spillDir }
+ await ctx.plugin(LocalBashExecutor, { graceMs: 200 })
const bash = ctx.bash as LocalBashExecutor
- bash.internals = { spillDir }
const finished = bash.start(bash.resolve({ command: 'echo done' }))
await finished.done
expect(finished.status).toBe('completed')
- const running = bash.start(bash.resolve({ command: 'sleep 60' }))
+ const trapping = bash.start(bash.resolve({ command: 'trap \'\' TERM; echo armed; sleep 60' }))
+ await readUntil(trapping, 'armed')
- await fiber.dispose()
- // The teardown marks every LIVE entry killed; a settled process had
- // already left the map, so its status stays completed.
+ await managerFiber.dispose()
+ // A settled process was untouched; the live one died by escalation.
expect(finished.status).toBe('completed')
- expect(running.status).toBe('killed')
- await running.done
- expect(running.signal).toBe('SIGTERM')
+ await trapping.done
+ expect(trapping.status).toBe('killed')
+ expect(trapping.signal).toBe('SIGKILL')
})
})
diff --git a/packages/bash/bash-local/tsconfig.json b/packages/bash/bash-local/tsconfig.json
index a55c76f00a..ce24865d49 100644
--- a/packages/bash/bash-local/tsconfig.json
+++ b/packages/bash/bash-local/tsconfig.json
@@ -26,6 +26,9 @@
{
"path": "../../bash/bash"
},
+ {
+ "path": "../../process/process"
+ },
{
"path": "../../support/invariants"
}
diff --git a/packages/bash/bash-sandbox/package.json b/packages/bash/bash-sandbox/package.json
index 9faf613c71..b8737e0e68 100644
--- a/packages/bash/bash-sandbox/package.json
+++ b/packages/bash/bash-sandbox/package.json
@@ -38,6 +38,7 @@
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
+ "@deepseek-ai/dsh-process-local": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
diff --git a/packages/bash/bash-sandbox/src/index.ts b/packages/bash/bash-sandbox/src/index.ts
index b889692f3c..3f0fc0c55d 100644
--- a/packages/bash/bash-sandbox/src/index.ts
+++ b/packages/bash/bash-sandbox/src/index.ts
@@ -34,7 +34,7 @@ export type Config = LocalConfig
* mode; `result.sandbox` reports the mode and enforcement actually used.
*/
export class SandboxBashExecutor extends LocalBashExecutor {
- static inject = ['sandbox', 'sandboxPolicy']
+ static override inject = ['processes', 'sandbox', 'sandboxPolicy']
// No own Config: the sandbox default (mode + workspaceRoot) moved to
// ctx.sandboxPolicy, so this executor inherits LocalBashExecutor's Config
@@ -128,7 +128,7 @@ export class SandboxBashExecutor extends LocalBashExecutor {
* Wrap one shell command via the `ctx.sandbox` provider: hand over the
* exact `['bash', '-c', command]` argv this executor would spawn, get back
* the confined argv, and re-assemble it into the `exec …` command string
- * the inherited spawn path runs (the outer `bash -c` that `runBash` spawns
+ * the inherited spawn path runs (the outer `bash -c` the process manager spawns
* `exec`s into the runner, so no extra shell lingers). Provider errors
* (fail-closed `SANDBOX_UNAVAILABLE`) propagate to the caller unchanged.
*/
diff --git a/packages/bash/bash-sandbox/tests/sandbox.spec.ts b/packages/bash/bash-sandbox/tests/sandbox.spec.ts
index 6e8f2229a0..3510c71737 100644
--- a/packages/bash/bash-sandbox/tests/sandbox.spec.ts
+++ b/packages/bash/bash-sandbox/tests/sandbox.spec.ts
@@ -15,6 +15,7 @@ import { SANDBOX_UNAVAILABLE, SandboxProvider, SandboxUnavailableError } from '@
import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
+import LocalProcessManager from '@deepseek-ai/dsh-process-local'
import { classifyDenial, classifyRunnerFailure, shellQuote } from '../src/helpers.ts'
import type { Config } from '@deepseek-ai/dsh-bash-sandbox'
@@ -58,9 +59,10 @@ async function setup(
...mode !== undefined ? { mode } : {},
...workspaceRoot !== undefined ? { workspaceRoot } : {},
})
+ await ctx.plugin(LocalProcessManager)
+ ;(ctx.processes as LocalProcessManager).internals = { spillDir }
await ctx.plugin(SandboxBashExecutor, { graceMs: 200, ...execConfig })
const bash = ctx.bash as SandboxBashExecutor
- bash.internals = { spillDir }
return { ctx, bash, calls }
}
diff --git a/packages/bash/bash/package.json b/packages/bash/bash/package.json
index 93497df719..13c31ee2de 100644
--- a/packages/bash/bash/package.json
+++ b/packages/bash/bash/package.json
@@ -28,11 +28,13 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
+ "@deepseek-ai/dsh-process": "^0.0.1",
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
+ "@deepseek-ai/dsh-process": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
diff --git a/packages/bash/bash/src/types.ts b/packages/bash/bash/src/types.ts
index a504513417..5928687e93 100644
--- a/packages/bash/bash/src/types.ts
+++ b/packages/bash/bash/src/types.ts
@@ -1,19 +1,17 @@
/**
* Execution types for the bash executor seam. Background task semantics belong
- * to `@deepseek-ai/dsh-tasks`; this seam exposes only process handles.
+ * to `@deepseek-ai/dsh-tasks`; this seam exposes only process handles. The
+ * managed-environment and captured-output vocabulary is owned by the
+ * process-manager seam and re-exported here so bash consumers keep one import
+ * root.
* @module dsh-bash/types
*/
import type { SandboxEnforcement, SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox'
+import type { CollectedOutput, DshEnvironment } from '@deepseek-ai/dsh-process'
-/** Namespace prefix reserved for DeepSeek Harness-managed child environment facts. */
-export const DSH_ENV_PREFIX = 'DSH_' as const
-
-/** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */
-export type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}`
-
-/** Trusted DeepSeek Harness variables for one bash execution. */
-export type DshEnvironment = Readonly>
+export { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-process'
+export type { CollectedOutput, DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-process'
/**
* Sandbox facts for one run, present iff a sandboxing executor handled it.
@@ -110,16 +108,6 @@ export interface BashExecSpec {
sandboxPolicy: SandboxExecutionPolicy | undefined
}
-/** One captured stream: the (possibly truncated) text plus recovery info. */
-export interface CollectedOutput {
- /** Collected text — the TAIL of the stream when truncated. */
- text: string
- /** True when bytes were dropped from `text`. */
- truncated: boolean
- /** Path to a file holding the COMPLETE stream, when truncated and available. */
- spillPath?: string
-}
-
/** The outcome of one completed (or killed) foreground run. */
export interface BashRunResult {
/** Exit code; null when the process died from a signal. */
diff --git a/packages/bash/bash/tsconfig.json b/packages/bash/bash/tsconfig.json
index 51175e6080..ad38e8e3e1 100644
--- a/packages/bash/bash/tsconfig.json
+++ b/packages/bash/bash/tsconfig.json
@@ -14,6 +14,9 @@
{
"path": "../../../vendor/cordis"
},
+ {
+ "path": "../../process/process"
+ },
{
"path": "../../sandbox/sandbox"
},
diff --git a/packages/bash/tool-bash/package.json b/packages/bash/tool-bash/package.json
index a89e147e0e..821e9706c9 100644
--- a/packages/bash/tool-bash/package.json
+++ b/packages/bash/tool-bash/package.json
@@ -52,6 +52,7 @@
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
+ "@deepseek-ai/dsh-process-local": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts
index e1315c232a..1a1d8bb4db 100644
--- a/packages/bash/tool-bash/tests/integration.spec.ts
+++ b/packages/bash/tool-bash/tests/integration.spec.ts
@@ -11,6 +11,7 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test
import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
+import LocalProcessManager from '@deepseek-ai/dsh-process-local'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
@@ -29,6 +30,7 @@ async function harness(adapter: MockAdapter, sessionRoot?: string, dshHome?: str
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LocalTaskService)
await ctx.plugin(ToolTasks)
+ await ctx.plugin(LocalProcessManager)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
await ctx.plugin(ToolBash, dshHome === undefined ? {} : { dshHome })
ctx.llm.registerAdapter(['mock'], adapter)
diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts
index c2b0c3d31b..3f22551b83 100644
--- a/packages/bash/tool-bash/tests/tools.spec.ts
+++ b/packages/bash/tool-bash/tests/tools.spec.ts
@@ -17,6 +17,7 @@ import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import ApprovalService from '@deepseek-ai/dsh-user-approval'
import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
+import LocalProcessManager from '@deepseek-ai/dsh-process-local'
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import { processOutcome } from '../src/background.ts'
@@ -32,8 +33,9 @@ async function setup() {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
+ await ctx.plugin(LocalProcessManager)
+ ;(ctx.processes as LocalProcessManager).internals = { spillDir }
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 })
- ;(ctx.bash as LocalBashExecutor).internals = { spillDir }
await ctx.plugin(ToolBash)
return ctx
}
@@ -46,8 +48,9 @@ async function setupWithTasks() {
await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalTaskService)
await ctx.plugin(ToolTasks)
+ await ctx.plugin(LocalProcessManager)
+ ;(ctx.processes as LocalProcessManager).internals = { spillDir }
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 })
- ;(ctx.bash as LocalBashExecutor).internals = { spillDir }
await ctx.plugin(ToolBash)
return ctx
}
@@ -275,8 +278,9 @@ describe('bash tool', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
+ await ctx.plugin(LocalProcessManager)
+ ;(ctx.processes as LocalProcessManager).internals = { spillDir }
await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100, graceMs: 200 })
- ;(ctx.bash as LocalBashExecutor).internals = { spillDir }
await ctx.plugin(ToolBash)
const result = await call(ctx, 'bash', { command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i; done', description: 'test command' })
expect(text(result)).toContain('[output truncated; full output: ')
@@ -383,6 +387,7 @@ describe('bash tool', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
+ await ctx.plugin(LocalProcessManager)
await ctx.plugin(LocalBashExecutor, {})
const fiber = await ctx.plugin(ToolBash)
expect(ctx.tools.schemas()).toHaveLength(1)
@@ -400,6 +405,7 @@ describe('bash tool', () => {
// inject: ['tools', 'bash'] keeps the plugin pending until bash exists.
await ctx.plugin(ToolBash)
expect(ctx.tools.schemas()).toHaveLength(0)
+ await ctx.plugin(LocalProcessManager)
await ctx.plugin(LocalBashExecutor, {})
await new Promise(resolve => setTimeout(resolve, 0))
expect(ctx.tools.schemas()).toHaveLength(1)
@@ -411,6 +417,7 @@ describe('bash tool', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
+ await ctx.plugin(LocalProcessManager)
await ctx.plugin(LocalBashExecutor, {})
ToolBash.apply(ctx, {})
const schema = ctx.tools.schemas()[0]!
@@ -526,6 +533,7 @@ describe('background execution through the task runtime', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
+ await ctx.plugin(LocalProcessManager)
await ctx.plugin(LocalBashExecutor, {})
await ctx.plugin(ToolBash, { enableRunInBackground: false })
diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts
index 3681efa015..a15c306e2f 100644
--- a/packages/cordis/tool-cordis/src/api-catalog.ts
+++ b/packages/cordis/tool-cordis/src/api-catalog.ts
@@ -426,6 +426,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
],
},
+ {
+ key: 'processes',
+ summary: 'Abstract process manager.',
+ methods: [
+ {
+ signature: 'abstract spawn(spec: ProcessSpawnSpec): ProcessHandle',
+ 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 */',
+ },
+ ],
+ },
{
key: 'pty',
summary: 'In-process registry for replaceable PTY backends and exact-Agent sessions.',
@@ -1760,6 +1770,26 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'PresetSpec',
declaration: 'export interface PresetSpec {\n sandbox: SandboxMode;\n approval: ApprovalPolicy;\n name?: string;\n description?: string;\n}',
},
+ {
+ name: 'ProcessHandle',
+ declaration: 'export interface ProcessHandle {\n readonly pid: number;\n readonly stdout: ProcessOutputReader;\n readonly stderr: ProcessOutputReader;\n readonly done: Promise;\n kill(): void;\n}',
+ },
+ {
+ name: 'ProcessOutcome',
+ declaration: 'export interface ProcessOutcome {\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n stdout: CollectedOutput;\n stderr: CollectedOutput;\n}',
+ },
+ {
+ name: 'ProcessOutputRead',
+ declaration: 'export interface ProcessOutputRead {\n text: string;\n nextOffset: number;\n lossy: boolean;\n spillPath?: string;\n}',
+ },
+ {
+ name: 'ProcessOutputReader',
+ declaration: 'export interface ProcessOutputReader {\n readFrom(fromByte: number): ProcessOutputRead;\n}',
+ },
+ {
+ name: 'ProcessSpawnSpec',
+ declaration: 'export interface ProcessSpawnSpec {\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}',
+ },
{
name: 'PromptAssembly',
declaration: 'export interface PromptAssembly {\n sections: AssembledSection[];\n tools: ToolSchema[];\n variables: Record;\n}',
diff --git a/packages/examples/acp-demo/tests/built-bin.e2e.ts b/packages/examples/acp-demo/tests/built-bin.e2e.ts
index 02e82ac5ac..3c101f14b5 100644
--- a/packages/examples/acp-demo/tests/built-bin.e2e.ts
+++ b/packages/examples/acp-demo/tests/built-bin.e2e.ts
@@ -94,6 +94,8 @@ async function makeConsumer(): Promise {
await writeFile(join(dir, 'cordis.yml'), [
'- id: mock-llm',
' name: \'./mock-llm.mjs\'',
+ '- id: processes',
+ ' name: \'@deepseek-ai/dsh-process-local\'',
'- id: bash',
' name: \'@deepseek-ai/dsh-bash-local\'',
'- id: acp-agent',
diff --git a/packages/examples/acp-demo/tests/load-path.e2e.ts b/packages/examples/acp-demo/tests/load-path.e2e.ts
index 2c507a0bad..624db3d97a 100644
--- a/packages/examples/acp-demo/tests/load-path.e2e.ts
+++ b/packages/examples/acp-demo/tests/load-path.e2e.ts
@@ -35,6 +35,8 @@ const CORDIS_YML = `
name: '@deepseek-ai/dsh-llm-deepseek'
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY
+- id: processes
+ name: '@deepseek-ai/dsh-process-local'
- id: bash
name: '@deepseek-ai/dsh-bash-local'
- id: acp-agent
diff --git a/packages/examples/cli-demo/tests/built-bin.e2e.ts b/packages/examples/cli-demo/tests/built-bin.e2e.ts
index 5c3a6ad62e..0dcca62291 100644
--- a/packages/examples/cli-demo/tests/built-bin.e2e.ts
+++ b/packages/examples/cli-demo/tests/built-bin.e2e.ts
@@ -80,6 +80,8 @@ async function makeConsumer(): Promise {
await writeFile(join(dir, 'cordis.yml'), [
'- id: mock-llm',
" name: './mock-llm.ts'",
+ '- id: processes',
+ " name: '@deepseek-ai/dsh-process-local'",
'- id: bash',
" name: '@deepseek-ai/dsh-bash-local'",
'- id: cli-agent',
diff --git a/packages/fs/tool-fs-search/package.json b/packages/fs/tool-fs-search/package.json
index 45de8f5e4a..c0abc66a50 100644
--- a/packages/fs/tool-fs-search/package.json
+++ b/packages/fs/tool-fs-search/package.json
@@ -44,6 +44,7 @@
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
+ "@deepseek-ai/dsh-process-local": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-retention": "workspace:^",
diff --git a/packages/fs/tool-fs-search/tests/integration.spec.ts b/packages/fs/tool-fs-search/tests/integration.spec.ts
index c497d7e428..3ba8e86c36 100644
--- a/packages/fs/tool-fs-search/tests/integration.spec.ts
+++ b/packages/fs/tool-fs-search/tests/integration.spec.ts
@@ -18,6 +18,7 @@ import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
+import LocalProcessManager from '@deepseek-ai/dsh-process-local'
import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search'
const testToolSignal = new AbortController().signal
@@ -61,6 +62,7 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', ()
ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
+ await ctx.plugin(LocalProcessManager)
await ctx.plugin(LocalBashExecutor, { cwd: dir, timeoutMs: 20_000 })
await ctx.plugin(ToolFsSearch)
})
diff --git a/packages/hooks/hooks-claude/package.json b/packages/hooks/hooks-claude/package.json
index 34696ab625..bf333718fa 100644
--- a/packages/hooks/hooks-claude/package.json
+++ b/packages/hooks/hooks-claude/package.json
@@ -46,6 +46,7 @@
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
+ "@deepseek-ai/dsh-process-local": "workspace:^",
"@deepseek-ai/dsh-hook-protocol": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts
index f5fd5702d5..3cf70d569a 100644
--- a/packages/hooks/hooks-claude/tests/bridge.spec.ts
+++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts
@@ -10,6 +10,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
+import LocalProcessManager from '@deepseek-ai/dsh-process-local'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import SubagentService, { SubagentRunId } from '@deepseek-ai/dsh-subagent'
import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude'
@@ -52,6 +53,7 @@ async function harnessWithFiber(configDir: string, adapter: MockAdapter): Promis
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
+ await ctx.plugin(LocalProcessManager)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
const hooks = await ctx.plugin(HooksClaude, { configPath: join(configDir, 'hooks.json') })
ctx.llm.registerAdapter(['mock'], adapter)
@@ -353,6 +355,7 @@ describe('hooks-claude bridge — load resilience', () => {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
+ await ctx.plugin(LocalProcessManager)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
await ctx.plugin(HooksClaude, { configPath: '/nonexistent/hooks.json' })
ctx.llm.registerAdapter(['mock'], adapter)
@@ -374,6 +377,7 @@ describe('hooks-claude bridge — load resilience', () => {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
+ await ctx.plugin(LocalProcessManager)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
const fiber = await ctx.plugin(HooksClaude, { configPath: join(dir, 'hooks.json') })
await fiber.dispose()
diff --git a/packages/hooks/hooks-claude/tests/coverage-cases.ts b/packages/hooks/hooks-claude/tests/coverage-cases.ts
index 8a3c44c5bd..1470c5f9a9 100644
--- a/packages/hooks/hooks-claude/tests/coverage-cases.ts
+++ b/packages/hooks/hooks-claude/tests/coverage-cases.ts
@@ -10,6 +10,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
+import LocalProcessManager from '@deepseek-ai/dsh-process-local'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import SubagentService, { SubagentRunId } from '@deepseek-ai/dsh-subagent'
import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude'
@@ -41,6 +42,7 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp
await mountAgentLoopTestDependencies(ctx)
if (opts.sessionRoot !== undefined) await ctx.plugin(SessionPersistenceJsonl, { root: opts.sessionRoot })
await ctx.plugin(AgentLoop, { agents: [] })
+ await ctx.plugin(LocalProcessManager)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
await ctx.plugin(HooksClaude, { configPath, ...opts })
ctx.llm.registerAdapter(['mock'], adapter)
@@ -359,6 +361,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
+ await ctx.plugin(LocalProcessManager)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
// Direct apply with only configPath — bypasses schemastery's defaults, so
// the bridge must run on the raw minimal config (the per-hook timeout is
@@ -657,6 +660,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
// Executor default cwd = serverDir (deliberately NOT the session cwd).
+ await ctx.plugin(LocalProcessManager)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir })
await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') })
ctx.llm.registerAdapter(['mock'], adapter)
@@ -686,6 +690,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
// Executor default cwd = serverDir (deliberately NOT the child session cwd).
+ await ctx.plugin(LocalProcessManager)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir })
await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') })
ctx.llm.registerAdapter(['mock'], new MockAdapter([]))
diff --git a/packages/hooks/hooks-codex/package.json b/packages/hooks/hooks-codex/package.json
index 5d583baafe..472e010a93 100644
--- a/packages/hooks/hooks-codex/package.json
+++ b/packages/hooks/hooks-codex/package.json
@@ -45,6 +45,7 @@
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
+ "@deepseek-ai/dsh-process-local": "workspace:^",
"@deepseek-ai/dsh-hook-protocol": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts
index 923a4bf8b5..eb34a5c1f8 100644
--- a/packages/hooks/hooks-codex/tests/bridge.spec.ts
+++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts
@@ -10,6 +10,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
+import LocalProcessManager from '@deepseek-ai/dsh-process-local'
import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
@@ -41,6 +42,7 @@ async function harness(dir: string, adapter: MockAdapter): Promise {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
+ await ctx.plugin(LocalProcessManager)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'test-model' })
ctx.llm.registerAdapter(['mock'], adapter)
@@ -164,6 +166,7 @@ describe('hooks-codex bridge', () => {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
+ await ctx.plugin(LocalProcessManager)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
const fiber = await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'm' })
await fiber.dispose()
@@ -186,6 +189,7 @@ describe('hooks-codex bridge', () => {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
+ await ctx.plugin(LocalProcessManager)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
const fiber = await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'm' })
ctx.llm.registerAdapter(['mock'], new MockAdapter([]))
diff --git a/packages/hooks/hooks-codex/tests/coverage-cases.ts b/packages/hooks/hooks-codex/tests/coverage-cases.ts
index ebb2164902..4bfaf4f98a 100644
--- a/packages/hooks/hooks-codex/tests/coverage-cases.ts
+++ b/packages/hooks/hooks-codex/tests/coverage-cases.ts
@@ -10,6 +10,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
+import LocalProcessManager from '@deepseek-ai/dsh-process-local'
import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
@@ -31,6 +32,7 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp
await mountAgentLoopTestDependencies(ctx)
if (opts.sessionRoot !== undefined) await ctx.plugin(SessionPersistenceJsonl, { root: opts.sessionRoot })
await ctx.plugin(AgentLoop, { agents: [] })
+ await ctx.plugin(LocalProcessManager)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
await ctx.plugin(HooksCodex, { configPath, model: 'm', ...opts })
ctx.llm.registerAdapter(['mock'], adapter)
@@ -310,6 +312,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
+ await ctx.plugin(LocalProcessManager)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
ctx.logger.warn = warn as never
// Direct apply (schema bypass) → the `model ?? ''` fallback is exercised.
@@ -619,6 +622,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
+ await ctx.plugin(LocalProcessManager)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir })
await ctx.plugin(HooksCodex, { configPath: join(serverDir, 'hooks.json'), model: 'm' })
ctx.llm.registerAdapter(['mock'], adapter)
diff --git a/packages/process/README.md b/packages/process/README.md
new file mode 100644
index 0000000000..956f08e3fe
--- /dev/null
+++ b/packages/process/README.md
@@ -0,0 +1,10 @@
+# process/ — child-process manager 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 [process-manager seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.md).
+
+| Package | ctx key | Role |
+|---|---|---|
+| [`process`](process/README.md) (`@deepseek-ai/dsh-process`) | `ctx.processes` | The seam: abstract `ProcessManager.spawn(spec)`, the fully-explicit `ProcessSpawnSpec`, `ProcessHandle` with offset-based readers, and the shared `DSH_*` managed-environment and `CollectedOutput` vocabulary |
+| [`process-local`](process-local/README.md) (`@deepseek-ai/dsh-process-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 |
+
+The manager 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/process/process-local/README.md b/packages/process/process-local/README.md
new file mode 100644
index 0000000000..595e42af2f
--- /dev/null
+++ b/packages/process/process-local/README.md
@@ -0,0 +1,27 @@
+# @deepseek-ai/dsh-process-local
+
+Local-subprocess implementation of the [`@deepseek-ai/dsh-process`](../process/README.md) manager seam: `LocalProcessManager` 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).
+
+## 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.
+- **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** — `ProcessHandle` readers return deltas in whole-stream byte coordinates; the manager never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist.
+- **Kill-and-join disposal** — the manager 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.
+
+## Model Experience
+
+Indirectly, through consumer seams (today the bash executor family behind `dsh-tool-bash`), which own all model-facing rendering of process output and lifecycle.
+
+#### KV Cache effect
+
+No direct invalidation; the named consumers own any request-prefix changes.
+
+## Known Limitations and Deferred Work
+
+- **POSIX-only** — detached process groups, group kills, and SIGTERM→SIGKILL escalation are hardcoded; Windows is unsupported.
+- **The credential scrub is a name heuristic** — `*KEY*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSWORD*`) pass through, and a whitelist for over-scrubbed vars is noted future work.
+- **Completed spill files are not deleted** — bounded full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them; oversize incomplete spills are discarded and deletion is attempted immediately, but a cleanup failure can leave a bounded file behind.
+
+The raw process handling lives in `src/spawn.ts`; `src/index.ts` is the service wiring.
diff --git a/packages/process/process-local/package.json b/packages/process/process-local/package.json
new file mode 100644
index 0000000000..22d51aadff
--- /dev/null
+++ b/packages/process/process-local/package.json
@@ -0,0 +1,39 @@
+{
+ "name": "@deepseek-ai/dsh-process-local",
+ "description": "Local-subprocess implementation of the DeepSeek Harness process-manager seam",
+ "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",
+ "@deepseek-ai/dsh-process": "^0.0.1",
+ "cordis": "^4.0.0-rc.7"
+ },
+ "devDependencies": {
+ "@deepseek-ai/dsh-invariants": "workspace:^",
+ "@deepseek-ai/dsh-process": "workspace:^",
+ "cordis": "^4.0.0-rc.7"
+ }
+}
diff --git a/packages/process/process-local/src/index.ts b/packages/process/process-local/src/index.ts
new file mode 100644
index 0000000000..6971a58a71
--- /dev/null
+++ b/packages/process/process-local/src/index.ts
@@ -0,0 +1,53 @@
+/**
+ * Local-subprocess implementation of the process-manager seam. Each spawn is
+ * a detached process group with bounded, spill-backed output; disposal kills
+ * and joins live groups. It has no config: every limit arrives on the spec,
+ * so the deployment-varying choices stay with the calling seam's config (the
+ * bash executor's, today).
+ * @module @deepseek-ai/dsh-process-local
+ */
+
+import { Context } from 'cordis'
+import { ProcessManager } from '@deepseek-ai/dsh-process'
+import type { ProcessHandle, ProcessSpawnSpec } from '@deepseek-ai/dsh-process'
+import { spawnProcess } from './spawn.ts'
+import type { SpawnInternals } from './spawn.ts'
+
+/**
+ * Local process manager: detached process groups, tail-keep truncation with
+ * bounded spill files, credential-scrubbed environment, and group
+ * SIGTERM→grace→SIGKILL escalation.
+ */
+export class LocalProcessManager extends ProcessManager {
+ /** Live handles retained only so disposal can kill and join them. */
+ private live = new Set()
+ /** Test seam: spill knobs forwarded to spawnProcess. */
+ internals: SpawnInternals = {}
+
+ constructor(ctx: Context) {
+ super(ctx)
+ ctx.effect(() => async () => {
+ // Await closure so even a TERM-trapping child cannot outlive the fiber.
+ const pending: Promise[] = []
+ for (const handle of this.live) {
+ handle.kill()
+ // Spawn-failure rejections already settled and left the live set.
+ pending.push(handle.done.catch(() => {}))
+ }
+ this.live.clear()
+ await Promise.all(pending)
+ }, 'local process-manager teardown')
+ }
+
+ spawn(spec: ProcessSpawnSpec): ProcessHandle {
+ const handle = spawnProcess(spec, this.internals)
+ this.live.add(handle)
+ handle.done.then(
+ () => { this.live.delete(handle) },
+ () => { this.live.delete(handle) },
+ )
+ return handle
+ }
+}
+
+export default LocalProcessManager
diff --git a/packages/process/process-local/src/invariant.ts b/packages/process/process-local/src/invariant.ts
new file mode 100644
index 0000000000..fa6c0c810e
--- /dev/null
+++ b/packages/process/process-local/src/invariant.ts
@@ -0,0 +1,30 @@
+/**
+ * Package-owned invariant companion for `@deepseek-ai/dsh-process-local`.
+ * @module @deepseek-ai/dsh-process-local/invariant
+ */
+
+/* jscpd:ignore-start */
+import type { Context } from 'cordis'
+import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+
+const PACKAGE_NAME = '@deepseek-ai/dsh-process-local'
+
+/** Cordis companion plugin name. */
+export const name = 'process-local-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/bash/bash-local/src/run.ts b/packages/process/process-local/src/spawn.ts
similarity index 66%
rename from packages/bash/bash-local/src/run.ts
rename to packages/process/process-local/src/spawn.ts
index 600e920c96..747b85cf13 100644
--- a/packages/bash/bash-local/src/run.ts
+++ b/packages/process/process-local/src/spawn.ts
@@ -1,8 +1,9 @@
/**
- * Process plumbing for the local bash executor: detached process-group spawn,
- * tail-keep output with spill files, and SIGTERM→SIGKILL escalation. This layer
- * reacts to an abort signal; the executor owns deadlines and classifies causes.
- * @module dsh-bash-local/run
+ * Process plumbing for the local process manager: detached process-group
+ * spawn, tail-keep output with spill files, and SIGTERM→SIGKILL escalation.
+ * This layer reacts to an abort signal; callers own deadlines and classify
+ * causes.
+ * @module dsh-process-local/spawn
*/
import { type ChildProcessByStdio, spawn } from 'node:child_process'
@@ -11,23 +12,11 @@ import { randomBytes } from 'node:crypto'
import { closeSync, mkdtempSync, openSync, unlinkSync, writeSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
-import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash'
-import type { CollectedOutput, DshEnvironment } from '@deepseek-ai/dsh-bash'
+import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-process'
+import type { CollectedOutput, DshEnvironment, ProcessHandle, ProcessOutcome, ProcessSpawnSpec } from '@deepseek-ai/dsh-process'
/**
- * Model-friendly environment overrides: disable colors, pagers, and
- * interactive terminal features that would garble tool output (the same set
- * Codex hardcodes; Claude Code achieves it via TERM=dumb).
- */
-export const ENV_OVERRIDES = {
- NO_COLOR: '1',
- TERM: 'dumb',
- PAGER: 'cat',
- GIT_PAGER: 'cat',
-} as const
-
-/**
- * Credential-shaped env vars are NOT forwarded to commands (the harness's
+ * Credential-shaped env vars are NOT forwarded to children (the harness's
* own DEEPSEEK_API_KEY must not leak into `env` output, tool results, or
* spill files). Same default pattern as Codex's env policy; a future config
* can whitelist specific vars when a workflow genuinely needs one.
@@ -35,10 +24,10 @@ export const ENV_OVERRIDES = {
export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
/**
- * Build a child environment from scrubbed ambient values, terminal overrides,
- * ordinary caller entries, and a managed `DSH_*` snapshot. Ambient managed
- * names are removed; ordinary and managed entries reject the other channel's
- * namespace before `dshEnv` merges last.
+ * Build a child environment from scrubbed ambient values, ordinary caller
+ * entries, and a managed `DSH_*` snapshot. Ambient managed names are removed;
+ * ordinary and managed entries reject the other channel's namespace before
+ * `dshEnv` merges last.
* @param extra - caller entries; `DSH_*` names are rejected.
* @param dshEnv - managed entries; non-`DSH_*` names are rejected.
* @returns the environment to hand to `spawn` for the child process.
@@ -53,77 +42,23 @@ export function childEnv(
}
for (const key of Object.keys(extra ?? {})) {
if (key.startsWith(DSH_ENV_PREFIX)) {
- throw new Error(`ordinary bash env cannot set reserved variable "${key}"; use dshEnv`)
+ throw new Error(`ordinary child env cannot set reserved variable "${key}"; use dshEnv`)
}
}
for (const key of Object.keys(dshEnv ?? {})) {
if (!key.startsWith(DSH_ENV_PREFIX)) {
- throw new Error(`managed bash env cannot set ordinary variable "${key}"; use env`)
+ throw new Error(`managed child env cannot set ordinary variable "${key}"; use env`)
}
}
- return { ...env, ...ENV_OVERRIDES, ...extra, ...dshEnv }
-}
-
-/** What to run and under which limits (resolved — no defaults in here). */
-export interface SpawnSpec {
- command: string
- 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 shell exit. */
- graceMs: number
- /**
- * Abort signal — kills the process group when it fires. The executor owns
- * timing: `run()` passes a fused timeout/cancel deadline signal (see
- * `@deepseek-ai/dsh-timeout`), `start()` passes the bare upstream signal.
- * runBash only listens and kills; it does NOT classify why (the executor
- * reads the signal's reason afterward).
- */
- signal?: AbortSignal | undefined
- /**
- * Bytes to write to the child's stdin, then close it. Absent (or empty)
- * leaves stdin closed/empty. Set by in-process plugins (the hooks bridges);
- * the model-facing `dsh-tool-bash` tool does not thread model input here.
- */
- stdin?: string | undefined
- /**
- * Ordinary environment entries merged after the credential scrub and
- * terminal overrides. `DSH_*` names are rejected and belong in `dshEnv`.
- */
- env?: Record | undefined
- /** Harness-owned entries; non-`DSH_*` names are rejected before spawn. */
- dshEnv?: DshEnvironment | undefined
-}
-
-/**
- * Raw outcome of one closed process (before result shaping). Deliberately
- * carries NO timeout/cancel classification: runBash kills on abort but does not
- * decide why — the executor's `run()`/`start()` reads the deadline signal it
- * owns to classify `timedOut`/`aborted` (see the package README).
- */
-export interface SpawnOutcome {
- exitCode: number | null
- signal: NodeJS.Signals | null
- stdout: CollectedOutput
- stderr: CollectedOutput
+ return { ...env, ...extra, ...dshEnv }
}
/** Injectable knobs so tests can exercise spill behavior without the OS tmpdir. */
-export interface RunInternals {
+export interface SpawnInternals {
/** Directory for spill files (defaults to the OS temp dir). */
spillDir?: string
}
-/** Default SIGTERM→SIGKILL grace period (the `graceMs` config; matches OpenCode's 3s). */
-export const DEFAULT_GRACE_MS = 3_000
-
-/** Default per-stream spill cap (the `maxSpillBytes` config). */
-export const DEFAULT_MAX_SPILL_BYTES = 64 * 1024 * 1024
-
let spillCounter = 0
let defaultSpillDir: string | undefined
@@ -133,7 +68,7 @@ let defaultSpillDir: string | undefined
* other local users read command output or pre-create symlinks.
*/
function privateSpillDir(): string {
- defaultSpillDir ??= mkdtempSync(join(tmpdir(), 'dsh-bash-'))
+ defaultSpillDir ??= mkdtempSync(join(tmpdir(), 'dsh-proc-'))
return defaultSpillDir
}
@@ -205,7 +140,7 @@ export class OutputCollector {
// prediction and symlink planting in shared tmp dirs.
this.spillFile = join(
this.spillDir,
- `dsh-bash-${process.pid}-${++spillCounter}-${randomBytes(6).toString('hex')}-${this.label}.log`,
+ `dsh-proc-${process.pid}-${++spillCounter}-${randomBytes(6).toString('hex')}-${this.label}.log`,
)
this.spillFd = openSync(this.spillFile, 'wx', 0o600)
for (const prior of this.chunks) writeSync(this.spillFd, prior)
@@ -300,41 +235,28 @@ export function killGroup(pid: number, sig: NodeJS.Signals): void {
}
/**
- * A live bash child process: the promise resolves when the process closes;
- * `kill()` starts the SIGTERM→grace→SIGKILL escalation on its group.
- */
-export interface RunningBash {
- /** Process id (group leader); -1 when the spawn itself failed. */
- readonly pid: number
- /** stdout/stderr collectors (live — background polling reads incrementally). */
- readonly stdout: OutputCollector
- readonly stderr: OutputCollector
- /** Resolves when the process closes; rejects only for spawn-level failures. */
- readonly done: Promise
- /** Begin SIGTERM→grace→SIGKILL on the process group. Idempotent. */
- kill(): void
-}
-
-/**
- * Spawn one isolated `bash -c` process group and collect its output.
- * Runtime exits resolve as {@link SpawnOutcome}; only spawn failures reject.
- * @param spec - fully resolved command, cwd, limits, and cancellation.
- * @param internals - test-only process and spill-directory overrides.
+ * Spawn one isolated detached process group and collect its output.
+ * Runtime exits resolve as {@link ProcessOutcome}; only spawn failures reject.
+ * @param spec - fully resolved argv, cwd, limits, and cancellation.
+ * @param internals - test-only spill-directory override.
* @returns live process handle and outcome promise.
*/
-// XXX(stateful-shell): evaluate persistent cwd or PTY sessions when workflows require shell state.
-export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningBash {
+export function spawnProcess(spec: ProcessSpawnSpec, internals: SpawnInternals = {}): ProcessHandle {
const spillDir = internals.spillDir ?? privateSpillDir()
if (spec.signal?.aborted) {
throw new Error(`aborted before spawn: ${String(spec.signal.reason ?? 'aborted')}`)
}
+ const [program, ...args] = spec.argv
+ if (program === undefined || program.length === 0) {
+ throw new Error('invalid argv: expected a non-empty program name at argv[0]')
+ }
// Keep absent stdin as /dev/null; literal tuples preserve non-null output types.
const env = childEnv(spec.env, spec.dshEnv)
const child: ChildProcessByStdio = spec.stdin !== undefined
- ? spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true })
- : spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['ignore', 'pipe', 'pipe'], detached: true })
+ ? spawn(program, args, { cwd: spec.cwd, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true })
+ : spawn(program, args, { cwd: spec.cwd, env, stdio: ['ignore', 'pipe', 'pipe'], detached: true })
const stdout = new OutputCollector(spec.stdoutMaxBytes, spec.maxSpillBytes, 'stdout', spillDir)
const stderr = new OutputCollector(spec.stderrMaxBytes, spec.maxSpillBytes, 'stderr', spillDir)
@@ -352,7 +274,7 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
graceTimer = setTimeout(() => { killGroup(pid, 'SIGKILL') }, spec.graceMs)
}
- // The executor owns timeout classification; this layer only reacts to abort.
+ // The caller owns timeout classification; this layer only reacts to abort.
const onAbort = (): void => { kill() }
spec.signal?.addEventListener('abort', onAbort, { once: true })
@@ -362,7 +284,7 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
child.stdin.end(spec.stdin)
}
- const done = new Promise((resolve, reject) => {
+ const done = new Promise((resolve, reject) => {
let settled = false
let pipeDrainTimer: NodeJS.Timeout | undefined
const settle = (exitCode: number | null, signal: NodeJS.Signals | null): void => {
diff --git a/packages/process/process-local/tests/manager.spec.ts b/packages/process/process-local/tests/manager.spec.ts
new file mode 100644
index 0000000000..719dcc1d44
--- /dev/null
+++ b/packages/process/process-local/tests/manager.spec.ts
@@ -0,0 +1,70 @@
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import LocalProcessManager from '@deepseek-ai/dsh-process-local'
+import type { ProcessSpawnSpec } from '@deepseek-ai/dsh-process'
+
+function spec(command: string, overrides: Partial = {}): ProcessSpawnSpec {
+ return {
+ argv: ['bash', '-c', command],
+ cwd: process.cwd(),
+ stdoutMaxBytes: 64_000,
+ stderrMaxBytes: 64_000,
+ maxSpillBytes: 64 * 1024 * 1024,
+ graceMs: 200,
+ ...overrides,
+ }
+}
+
+describe('LocalProcessManager', () => {
+ it('registers as ctx.processes and spawns managed handles', async () => {
+ const ctx = new Context()
+ const fiber = await ctx.plugin(LocalProcessManager)
+ const result = await ctx.processes.spawn(spec('echo managed')).done
+ expect(result.exitCode).toBe(0)
+ expect(result.stdout.text).toBe('managed\n')
+ await fiber.dispose()
+ })
+
+ it('disposal kills still-running processes and awaits their exit', async () => {
+ const ctx = new Context()
+ const fiber = await ctx.plugin(LocalProcessManager)
+ const handle = ctx.processes.spawn(spec('sleep 60'))
+ await fiber.dispose()
+ const outcome = await handle.done
+ expect(outcome.signal).toBe('SIGTERM')
+ })
+
+ it('a settled process leaves the live set (disposal does not re-kill it)', async () => {
+ const ctx = new Context()
+ const fiber = await ctx.plugin(LocalProcessManager)
+ const handle = ctx.processes.spawn(spec('true'))
+ const outcome = await handle.done
+ expect(outcome.exitCode).toBe(0)
+ await fiber.dispose()
+ })
+
+ it('disposal tolerates a handle whose spawn already failed', async () => {
+ const ctx = new Context()
+ const fiber = await ctx.plugin(LocalProcessManager)
+ const handle = ctx.processes.spawn(spec('true', { cwd: '/nonexistent-dir-dsh-manager-test' }))
+ await expect(handle.done).rejects.toThrow()
+ await fiber.dispose()
+ })
+
+ it('disposal contains a spawn-failure rejection that races teardown', async () => {
+ const ctx = new Context()
+ const fiber = await ctx.plugin(LocalProcessManager)
+ // Dispose before the rejection continuation removes the handle from the
+ // live set, so teardown itself must swallow the rejected done.
+ const handle = ctx.processes.spawn(spec('true', { cwd: '/nonexistent-dir-dsh-manager-test' }))
+ await fiber.dispose()
+ await expect(handle.done).rejects.toThrow()
+ })
+
+ it('loading a second implementation throws (one processes service per context — cordis standard)', async () => {
+ const ctx = new Context()
+ await ctx.plugin(LocalProcessManager)
+ class SecondManager extends LocalProcessManager {}
+ await expect(ctx.plugin(SecondManager)).rejects.toThrow(/service "processes" has been registered/)
+ })
+})
diff --git a/packages/bash/bash-local/tests/run.spec.ts b/packages/process/process-local/tests/spawn.spec.ts
similarity index 78%
rename from packages/bash/bash-local/tests/run.spec.ts
rename to packages/process/process-local/tests/spawn.spec.ts
index 91afd1aede..954196e104 100644
--- a/packages/bash/bash-local/tests/run.spec.ts
+++ b/packages/process/process-local/tests/spawn.spec.ts
@@ -2,9 +2,9 @@ import { mkdtempSync, readFileSync, statSync, unlinkSync } from 'node:fs'
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-bash'
-import { killGroup, OutputCollector, runBash } from '../src/run.ts'
-import type { RunningBash } from '../src/run.ts'
+import type { DshEnvironment } from '@deepseek-ai/dsh-process'
+import { killGroup, OutputCollector, spawnProcess } from '../src/spawn.ts'
+import type { ProcessHandle } from '@deepseek-ai/dsh-process'
const { failNextClose, failNextUnlink } = vi.hoisted(() => ({
failNextClose: { value: false },
@@ -31,11 +31,11 @@ vi.mock('node:fs', async (importOriginal) => {
}
})
-const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-spec-'))
+const spillDir = mkdtempSync(join(tmpdir(), 'dsh-proc-spec-'))
-function spec(command: string, overrides: Partial[0]> = {}) {
+function spec(command: string, overrides: Partial[0]> = {}) {
return {
- command,
+ argv: ['bash', '-c', command],
cwd: process.cwd(),
stdoutMaxBytes: 64_000,
stderrMaxBytes: 64_000,
@@ -59,7 +59,7 @@ async function waitGone(pid: number, timeoutMs = 5_000): Promise {
throw new Error(`pid ${pid} still alive after ${timeoutMs}ms`)
}
-async function waitForStdout(running: RunningBash, expected: string, timeoutMs = 5_000): Promise {
+async function waitForStdout(running: ProcessHandle, expected: string, timeoutMs = 5_000): Promise {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (running.stdout.readFrom(0).text.includes(expected)) return
@@ -82,9 +82,9 @@ async function waitForPidFile(path: string, timeoutMs = 5_000): Promise
throw new Error(`pid file ${path} was not written after ${timeoutMs}ms`)
}
-describe('runBash', () => {
+describe('spawnProcess', () => {
it('captures stdout on success', async () => {
- const result = await runBash(spec('echo hello')).done
+ const result = await spawnProcess(spec('echo hello')).done
expect(result.exitCode).toBe(0)
expect(result.signal).toBeNull()
expect(result.stdout.text).toBe('hello\n')
@@ -93,41 +93,43 @@ describe('runBash', () => {
})
it('captures stderr separately', async () => {
- const result = await runBash(spec('echo oops >&2')).done
+ const result = await spawnProcess(spec('echo oops >&2')).done
expect(result.exitCode).toBe(0)
expect(result.stdout.text).toBe('')
expect(result.stderr.text).toBe('oops\n')
})
it('captures both streams', async () => {
- const result = await runBash(spec('echo out; echo err >&2')).done
+ const result = await spawnProcess(spec('echo out; echo err >&2')).done
expect(result.stdout.text).toBe('out\n')
expect(result.stderr.text).toBe('err\n')
})
it('reports non-zero exit codes', async () => {
- const result = await runBash(spec('exit 42')).done
+ const result = await spawnProcess(spec('exit 42')).done
expect(result.exitCode).toBe(42)
expect(result.signal).toBeNull()
})
- it('applies model-friendly env overrides', async () => {
- const result = await runBash(spec('echo "$NO_COLOR/$TERM/$PAGER"')).done
- expect(result.stdout.text).toBe('1/dumb/cat\n')
+ it('passes the ambient TERM through untouched (terminal policy is the caller\'s)', async () => {
+ const result = await spawnProcess(spec('echo "${TERM:-unset}"', {
+ env: { TERM: 'callers-choice' },
+ })).done
+ expect(result.stdout.text).toBe('callers-choice\n')
})
it('runs in the requested cwd', async () => {
- const result = await runBash(spec('pwd', { cwd: '/tmp' })).done
+ const result = await spawnProcess(spec('pwd', { cwd: '/tmp' })).done
expect(result.stdout.text.trim()).toMatch(/\/tmp$/)
})
it('kills the process group with SIGTERM when the signal fires', async () => {
- // runBash owns no timer: it kills on abort. The executor drives the timeout
+ // spawnProcess owns no timer: it kills on abort. The bash executor drives the timeout
// by firing this signal via a deadline (see executor.spec.ts); here we
// assert the kill itself lands as SIGTERM.
const controller = new AbortController()
const start = Date.now()
- const running = runBash(spec('sleep 60', { signal: controller.signal }))
+ const running = spawnProcess(spec('sleep 60', { signal: controller.signal }))
setTimeout(() => { controller.abort('deadline') }, 100)
const result = await running.done
expect(Date.now() - start).toBeLessThan(5_000)
@@ -136,7 +138,7 @@ describe('runBash', () => {
})
it('escalates to SIGKILL when SIGTERM is trapped', async () => {
- const running = runBash(spec('trap \'\' TERM; echo ready; while :; do sleep 60 & wait $!; done', { graceMs: 200 }))
+ const running = spawnProcess(spec('trap \'\' TERM; echo ready; while :; do sleep 60 & wait $!; done', { graceMs: 200 }))
await waitForStdout(running, 'ready\n')
running.kill()
const result = await running.done
@@ -147,7 +149,7 @@ describe('runBash', () => {
// The subshell writes the sleep's pid then waits on it; killing the
// group must take the sleep down with bash.
const pidFile = join(spillDir, `grandchild-${Date.now()}.pid`)
- const running = runBash(spec(`sleep 60 & echo $! > ${pidFile}; wait`))
+ const running = spawnProcess(spec(`sleep 60 & echo $! > ${pidFile}; wait`))
const grandchild = await waitForPidFile(pidFile)
expect(grandchild).toBeGreaterThan(0)
@@ -159,7 +161,7 @@ describe('runBash', () => {
it('aborts via AbortSignal mid-run', async () => {
const controller = new AbortController()
- const running = runBash(spec('sleep 60', { signal: controller.signal }))
+ const running = spawnProcess(spec('sleep 60', { signal: controller.signal }))
setTimeout(() => { controller.abort('user cancelled') }, 50)
const result = await running.done
expect(result.signal).toBe('SIGTERM')
@@ -168,17 +170,17 @@ describe('runBash', () => {
it('throws when the signal is already aborted before spawn', () => {
const controller = new AbortController()
controller.abort('too late')
- expect(() => runBash(spec('echo hi', { signal: controller.signal })))
+ expect(() => spawnProcess(spec('echo hi', { signal: controller.signal })))
.toThrow(/aborted before spawn: too late/)
})
it('rejects with a spawn error for a nonexistent cwd', async () => {
- await expect(runBash(spec('echo hi', { cwd: '/nonexistent-dir-dsh-test' })).done)
+ await expect(spawnProcess(spec('echo hi', { cwd: '/nonexistent-dir-dsh-test' })).done)
.rejects.toThrow(/ENOENT/)
})
it('kill() is idempotent (second call does not restart escalation)', async () => {
- const running = runBash(spec('sleep 60'))
+ const running = spawnProcess(spec('sleep 60'))
running.kill()
running.kill()
const result = await running.done
@@ -188,7 +190,7 @@ describe('runBash', () => {
it('bounds inherited-pipe draining after the shell exits', async () => {
const pidFile = join(spillDir, `pipe-holder-${Date.now()}.pid`)
const started = Date.now()
- const running = runBash(spec(`sleep 60 & echo $! > ${pidFile}; echo shell-done`, { graceMs: 100 }))
+ const running = spawnProcess(spec(`sleep 60 & echo $! > ${pidFile}; echo shell-done`, { graceMs: 100 }))
const descendant = await waitForPidFile(pidFile)
try {
const result = await running.done
@@ -204,7 +206,7 @@ describe('runBash', () => {
describe('stdin and extra env (set by in-process plugins)', () => {
it('writes stdin to the command and closes it', async () => {
- const result = await runBash(spec('cat', { stdin: 'hello from stdin\n' })).done
+ const result = await spawnProcess(spec('cat', { stdin: 'hello from stdin\n' })).done
expect(result.exitCode).toBe(0)
expect(result.stdout.text).toBe('hello from stdin\n')
})
@@ -212,7 +214,7 @@ describe('stdin and extra env (set by in-process plugins)', () => {
it('a command that reads stdin sees EOF when none is supplied', async () => {
// No stdin → fd 0 is /dev/null, so `cat` reads EOF and exits 0 with no
// output (it does NOT block).
- const result = await runBash(spec('cat')).done
+ const result = await spawnProcess(spec('cat')).done
expect(result.exitCode).toBe(0)
expect(result.stdout.text).toBe('')
})
@@ -220,41 +222,40 @@ describe('stdin and extra env (set by in-process plugins)', () => {
it('gives fd 0 the exact pre-seam type: /dev/null when no stdin, a pipe when supplied', async () => {
// With no bytes, fd 0 remains the pre-seam `ignore` default (/dev/null, a character device).
// Supplied bytes use Node's spawn pipe, which is an AF_UNIX socket rather than a FIFO.
- const none = await runBash(spec('test -c /dev/stdin && echo char || echo other')).done
+ const none = await spawnProcess(spec('test -c /dev/stdin && echo char || echo other')).done
expect(none.stdout.text).toBe('char\n')
- const piped = await runBash(spec('test -S /dev/stdin && echo socket || echo other', { stdin: 'x' })).done
+ const piped = await spawnProcess(spec('test -S /dev/stdin && echo socket || echo other', { stdin: 'x' })).done
expect(piped.stdout.text).toBe('socket\n')
})
it('merges ordinary extra env entries onto the scrubbed environment', async () => {
- const result = await runBash(spec('echo "$EXTRA_ONE/$EXTRA_TWO"', {
+ const result = await spawnProcess(spec('echo "$EXTRA_ONE/$EXTRA_TWO"', {
env: { EXTRA_ONE: 'alpha', EXTRA_TWO: 'beta' },
})).done
expect(result.stdout.text).toBe('alpha/beta\n')
})
- it('an explicit extra env entry overrides the model-friendly override and the scrub', async () => {
- // TERM is a model-friendly OVERRIDE (dumb); an explicit extra entry wins.
+ it('an explicit extra env entry overrides the credential scrub', async () => {
// EXPLICIT_OVERRIDE_KEY matches the credential scrub pattern, yet an explicit
// entry is still honored — the scrub only drops AMBIENT process.env creds.
- const result = await runBash(spec('echo "$TERM/$EXPLICIT_OVERRIDE_KEY"', {
- env: { TERM: 'xterm-256color', EXPLICIT_OVERRIDE_KEY: 'explicit-wins' },
+ const result = await spawnProcess(spec('echo "$EXPLICIT_OVERRIDE_KEY"', {
+ env: { EXPLICIT_OVERRIDE_KEY: 'explicit-wins' },
})).done
- expect(result.stdout.text).toBe('xterm-256color/explicit-wins\n')
+ expect(result.stdout.text).toBe('explicit-wins\n')
})
it('does not crash or reject when the child ignores a large stdin (EPIPE)', async () => {
// The child exits without reading, so closing a stdin pipe holding ~1 MiB triggers EPIPE.
// The handler swallows that write error and `done` reports the child's real exit.
const big = 'x'.repeat(1024 * 1024)
- const result = await runBash(spec('exit 7', { stdin: big })).done
+ const result = await spawnProcess(spec('exit 7', { stdin: big })).done
expect(result.exitCode).toBe(7)
})
})
describe('output truncation and spill', () => {
it('applies stdout and stderr caps independently', async () => {
- const result = await runBash(
+ const result = await spawnProcess(
spec('printf "%.0sx" $(seq 1 500); printf "%.0se" $(seq 1 500) >&2', {
stdoutMaxBytes: 500,
stderrMaxBytes: 100,
@@ -269,7 +270,7 @@ describe('output truncation and spill', () => {
it('keeps the tail and spills the full stream to disk', async () => {
// 200 numbered lines of ~10 bytes; cap at 500 bytes keeps a late tail.
- const result = await runBash(
+ const result = await spawnProcess(
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
{ spillDir },
).done
@@ -284,7 +285,7 @@ describe('output truncation and spill', () => {
})
it('does not truncate output exactly at the cap', async () => {
- const result = await runBash(
+ const result = await spawnProcess(
spec('printf "%.0sx" $(seq 1 500)', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
{ spillDir },
).done
@@ -295,7 +296,7 @@ describe('output truncation and spill', () => {
it('settles with the tail and no spill path when final spill close fails', async () => {
failNextClose.value = true
- const result = await runBash(
+ const result = await spawnProcess(
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
{ spillDir },
).done
@@ -402,12 +403,27 @@ describe('killGroup', () => {
})
it('swallows ESRCH for vanished groups', async () => {
- const running = runBash(spec('true'))
+ const running = spawnProcess(spec('true'))
await running.done
expect(() => { killGroup(running.pid, 'SIGTERM') }).not.toThrow()
})
})
+describe('argv validation', () => {
+ it('rejects an empty argv before spawning', () => {
+ expect(() => spawnProcess({ ...spec('true'), argv: [] })).toThrow(/non-empty program name/)
+ })
+
+ it('rejects an empty program name before spawning', () => {
+ expect(() => spawnProcess({ ...spec('true'), argv: [''] })).toThrow(/non-empty program name/)
+ })
+
+ it('spawns argv verbatim without shell interpretation', async () => {
+ const result = await spawnProcess({ ...spec('unused'), argv: ['printf', '%s', '$HOME'] }).done
+ expect(result.stdout.text).toBe('$HOME')
+ })
+})
+
describe('abort edge cases', () => {
it('reports a fallback reason for reason-less pre-aborted signals', () => {
// Real AbortControllers always set a DOMException reason; signal-like
@@ -418,14 +434,14 @@ describe('abort edge cases', () => {
addEventListener() {},
removeEventListener() {},
} as unknown as AbortSignal
- expect(() => runBash(spec('echo hi', { signal: bare })))
+ expect(() => spawnProcess(spec('echo hi', { signal: bare })))
.toThrow(/aborted before spawn: aborted/)
})
it('reports the terminating signal of an externally self-killed command', async () => {
- // runBash reports the raw signal; whether it counts as timeout/cancel is the
+ // spawnProcess reports the raw signal; whether it counts as timeout/cancel is the
// executor's classification (a self-kill is neither) — see executor.spec.ts.
- const result = await runBash(spec('kill -TERM $$')).done
+ const result = await spawnProcess(spec('kill -TERM $$')).done
expect(result.signal).toBe('SIGTERM')
})
})
@@ -436,7 +452,7 @@ describe('environment and spill-file hardening', () => {
process.env.DSH_TEST_TOKEN = 'also-secret'
process.env.DSH_TEST_PLAIN = 'visible'
try {
- const result = await runBash(spec('echo "[${DSH_TEST_API_KEY:-absent}|${DSH_TEST_TOKEN:-absent}|${DSH_TEST_PLAIN:-absent}]"')).done
+ const result = await spawnProcess(spec('echo "[${DSH_TEST_API_KEY:-absent}|${DSH_TEST_TOKEN:-absent}|${DSH_TEST_PLAIN:-absent}]"')).done
expect(result.stdout.text.trim()).toBe('[absent|absent|absent]')
} finally {
delete process.env.DSH_TEST_API_KEY
@@ -448,7 +464,7 @@ describe('environment and spill-file hardening', () => {
it('injects only the current trusted DSH environment after scrubbing ambient values', async () => {
process.env.DSH_STALE = 'old-value'
try {
- const result = await runBash(spec('echo "[${DSH_STALE:-absent}|$DSH_SHELL|$DSH_SESSION_ID]"', {
+ const result = await spawnProcess(spec('echo "[${DSH_STALE:-absent}|$DSH_SHELL|$DSH_SESSION_ID]"', {
dshEnv: { DSH_SHELL: '1', DSH_SESSION_ID: 'current-session' },
})).done
expect(result.stdout.text.trim()).toBe('[absent|1|current-session]')
@@ -458,33 +474,33 @@ describe('environment and spill-file hardening', () => {
})
it('rejects DSH variables on the ordinary env channel', () => {
- expect(() => runBash(spec('true', { env: { DSH_WRONG_CHANNEL: 'bad' } })))
+ expect(() => spawnProcess(spec('true', { env: { DSH_WRONG_CHANNEL: 'bad' } })))
.toThrow(/DSH_WRONG_CHANNEL.*dshEnv/)
})
it('rejects ordinary variables on the managed env channel', () => {
const invalid = { PATH: '/wrong-channel' } as unknown as DshEnvironment
- expect(() => runBash(spec('true', { dshEnv: invalid })))
- .toThrow(/managed bash env.*PATH.*use env/)
+ expect(() => spawnProcess(spec('true', { dshEnv: invalid })))
+ .toThrow(/managed child env.*PATH.*use env/)
})
it('creates spill files with owner-only permissions and random names', async () => {
- const result = await runBash(
+ const result = await spawnProcess(
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
{ spillDir },
).done
const path = result.stdout.spillPath!
- expect(path).toMatch(/dsh-bash-\d+-\d+-[0-9a-f]{12}-stdout\.log$/)
+ expect(path).toMatch(/dsh-proc-\d+-\d+-[0-9a-f]{12}-stdout\.log$/)
const mode = statSync(path).mode & 0o777
expect(mode).toBe(0o600)
})
it('defaults spills into a private per-process directory', async () => {
- const result = await runBash(
+ const result = await spawnProcess(
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
).done
const dir = dirname(result.stdout.spillPath!)
- expect(dir).toMatch(/dsh-bash-/)
+ expect(dir).toMatch(/dsh-proc-/)
const mode = statSync(dir).mode & 0o777
expect(mode).toBe(0o700)
})
@@ -502,7 +518,7 @@ describe('environment and spill-file hardening', () => {
it('honors AbortSignal on background-style runs (no timeout)', async () => {
const controller = new AbortController()
- const running = runBash(spec('sleep 60', { signal: controller.signal }))
+ const running = spawnProcess(spec('sleep 60', { signal: controller.signal }))
setTimeout(() => { controller.abort() }, 50)
const result = await running.done
expect(result.signal).toBe('SIGTERM')
diff --git a/packages/process/process-local/tsconfig.json b/packages/process/process-local/tsconfig.json
new file mode 100644
index 0000000000..5f84f780af
--- /dev/null
+++ b/packages/process/process-local/tsconfig.json
@@ -0,0 +1,24 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "lib/types"
+ },
+ "include": [
+ "src"
+ ],
+ "references": [
+ {
+ "path": "../../../vendor/cosmokit"
+ },
+ {
+ "path": "../../../vendor/cordis"
+ },
+ {
+ "path": "../process"
+ },
+ {
+ "path": "../../support/invariants"
+ }
+ ]
+}
diff --git a/packages/process/process/README.md b/packages/process/process/README.md
new file mode 100644
index 0000000000..b53628cab3
--- /dev/null
+++ b/packages/process/process/README.md
@@ -0,0 +1,26 @@
+# @deepseek-ai/dsh-process
+
+The child-process manager seam (`ctx.processes`). The abstract `ProcessManager` exposes one method — `spawn(spec): ProcessHandle` — plus the vocabulary shared by every consumer: the fully-explicit `ProcessSpawnSpec`, `ProcessHandle` with its non-consuming offset-based output readers, `ProcessOutcome`, `CollectedOutput`, and the managed `DSH_*` environment namespace (`DSH_ENV_PREFIX`, `DshEnvironment`). The local implementation lives in [`dsh-process-local`](../process-local/README.md).
+
+## 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 process-manager 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 manager 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.
+
+See the [process data-structure catalog](../../../docs/core-data-structures/process.md) and the [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.md).
+
+## Model Experience
+
+Indirectly, through consumer seams (today the bash executor family behind `dsh-tool-bash`), which own all model-facing rendering of process output and lifecycle.
+
+#### KV Cache effect
+
+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.
diff --git a/packages/process/process/package.json b/packages/process/process/package.json
new file mode 100644
index 0000000000..6210948d56
--- /dev/null
+++ b/packages/process/process/package.json
@@ -0,0 +1,37 @@
+{
+ "name": "@deepseek-ai/dsh-process",
+ "description": "Child-process manager seam (ctx.processes) for the DeepSeek Harness — managed process groups, bounded spill-backed output, and escalated kills behind one abstract service",
+ "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/process/process/src/index.ts b/packages/process/process/src/index.ts
new file mode 100644
index 0000000000..4fe3386503
--- /dev/null
+++ b/packages/process/process/src/index.ts
@@ -0,0 +1,62 @@
+/**
+ * The child-process manager seam (`ctx.processes`): spawn fully-specified
+ * commands into managed process groups with bounded, spill-backed output and
+ * escalated kills. Command defaulting, shell semantics, deadlines, and
+ * presentation belong to consumers — the bash executor seam is the owning
+ * template. The local implementation lives in
+ * `@deepseek-ai/dsh-process-local`.
+ * @module @deepseek-ai/dsh-process
+ */
+
+import { Context, Service } from 'cordis'
+import type { ProcessHandle, ProcessSpawnSpec } from './types.ts'
+
+export { DSH_ENV_PREFIX } from './types.ts'
+export type {
+ CollectedOutput,
+ DshEnvironment,
+ DshEnvironmentKey,
+ ProcessHandle,
+ ProcessOutcome,
+ ProcessOutputRead,
+ ProcessOutputReader,
+ ProcessSpawnSpec,
+} from './types.ts'
+
+declare module 'cordis' {
+ interface Context {
+ processes: ProcessManager
+ }
+}
+
+/**
+ * Abstract process manager. Subclass, implement {@link spawn}, and load the
+ * subclass as a plugin — it registers as `ctx.processes` (one implementation
+ * per context; loading a second throws, which is cordis' standard
+ * duplicate-service behavior).
+ *
+ * Implementations must honor these semantics:
+ * - {@link 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.
+ * - {@link ProcessHandle.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.
+ */
+export abstract class ProcessManager extends Service {
+ constructor(ctx: Context) {
+ super(ctx, 'processes')
+ }
+
+ /**
+ * 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).
+ */
+ abstract spawn(spec: ProcessSpawnSpec): ProcessHandle
+}
+
+export default ProcessManager
diff --git a/packages/process/process/src/invariant.ts b/packages/process/process/src/invariant.ts
new file mode 100644
index 0000000000..f54476747e
--- /dev/null
+++ b/packages/process/process/src/invariant.ts
@@ -0,0 +1,22 @@
+/** Package-owned invariant companion for the process-manager seam. @module @deepseek-ai/dsh-process/invariant */
+
+import type { Context } from 'cordis'
+import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+
+const PACKAGE_NAME = '@deepseek-ai/dsh-process'
+
+/** Cordis companion plugin name. */
+export const name = 'process-invariant'
+/** Service required before the companion can reserve package ownership. */
+export const inject = ['invariants']
+
+/** No runtime invariant: this stateless seam owns spawn-spec/handle types, while implementations own observations. */
+const install: InvariantInstaller = () => {}
+
+/**
+ * Register the process-manager 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))
diff --git a/packages/process/process/src/types.ts b/packages/process/process/src/types.ts
new file mode 100644
index 0000000000..1b81ab8c07
--- /dev/null
+++ b/packages/process/process/src/types.ts
@@ -0,0 +1,128 @@
+/**
+ * Vocabulary for the process-manager seam: fully-specified spawn requests,
+ * bounded output with spill recovery, and live process handles. Command
+ * defaulting, shell semantics, and presentation belong to consumers such as
+ * the bash executor seam.
+ * @module dsh-process/types
+ */
+
+/** Namespace prefix reserved for DeepSeek Harness-managed child environment facts. */
+export const DSH_ENV_PREFIX = 'DSH_' as const
+
+/** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */
+export type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}`
+
+/** Trusted DeepSeek Harness variables for one child-process execution. */
+export type DshEnvironment = Readonly>
+
+/** One captured stream: the (possibly truncated) text plus recovery info. */
+export interface CollectedOutput {
+ /** Collected text — the TAIL of the stream when truncated. */
+ text: string
+ /** True when bytes were dropped from `text`. */
+ truncated: boolean
+ /** Path to a file holding the COMPLETE stream, when truncated and available. */
+ spillPath?: string
+}
+
+/**
+ * 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
+ * process-manager default — decides them (the `dsh-bash` request/spec split
+ * is the owning template).
+ */
+export interface ProcessSpawnSpec {
+ /** 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. */
+ 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.
+ */
+ 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}.
+ */
+ 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.
+ */
+ dshEnv?: DshEnvironment | undefined
+}
+
+/**
+ * Raw outcome of one closed process. Deliberately carries NO timeout or
+ * cancellation classification: the manager kills on abort but does not decide
+ * why — the caller reads the signal it owns to classify causes.
+ */
+export interface ProcessOutcome {
+ /** 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
+}
+
+/** One incremental {@link ProcessOutputReader.readFrom} read. */
+export interface ProcessOutputRead {
+ /** Stream text from the requested offset (the whole retained tail when lossy). */
+ text: string
+ /** Whole-stream offset to resume from on the next read. */
+ nextOffset: number
+ /** True when the requested offset slid out of the in-memory tail window. */
+ lossy: boolean
+ /** Path to the full-stream spill file, when one was created and remains intact. */
+ spillPath?: string
+}
+
+/**
+ * Cursor-free incremental access to one live output stream. Offsets are
+ * whole-stream byte coordinates owned by the caller, so independent readers
+ * cannot consume one another's output.
+ */
+export interface ProcessOutputReader {
+ /**
+ * Read everything captured since `fromByte`. When that offset has slid out
+ * of the in-memory tail window the read is `lossy` — it returns the whole
+ * retained tail and the gap is only recoverable from the spill file.
+ * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 for the first read).
+ * @returns the delta text, the next offset, the `lossy` flag, and the spill path when one exists.
+ */
+ readFrom(fromByte: number): ProcessOutputRead
+}
+
+/**
+ * A live child process. `kill()` starts the group SIGTERM→grace→SIGKILL
+ * escalation; buffered output remains readable after exit.
+ */
+export interface ProcessHandle {
+ /** Process id (group leader); -1 when the spawn itself failed. */
+ readonly pid: number
+ /** Live stdout reader (also readable after exit). */
+ readonly stdout: ProcessOutputReader
+ /** Live stderr reader (also readable after exit). */
+ readonly stderr: ProcessOutputReader
+ /** Resolves when the process closes; rejects only for spawn-level failures. */
+ readonly done: Promise
+ /** Begin SIGTERM→grace→SIGKILL on the process group. Idempotent. */
+ kill(): void
+}
diff --git a/packages/process/process/tests/service.spec.ts b/packages/process/process/tests/service.spec.ts
new file mode 100644
index 0000000000..04ffa8c9f4
--- /dev/null
+++ b/packages/process/process/tests/service.spec.ts
@@ -0,0 +1,55 @@
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import { ProcessManager } from '@deepseek-ai/dsh-process'
+import type { ProcessHandle, ProcessOutputRead, ProcessSpawnSpec } from '@deepseek-ai/dsh-process'
+
+/**
+ * Minimal concrete manager: a hand-built handle. The seam is spawn-only —
+ * defaulting, shell semantics, and deadlines belong to callers — so this stub
+ * is all an implementation owes the abstract class.
+ */
+class StubProcessManager extends ProcessManager {
+ spawn(spec: ProcessSpawnSpec): ProcessHandle {
+ const read: ProcessOutputRead = { text: '', nextOffset: 0, lossy: false }
+ let killed = false
+ return {
+ pid: spec.argv.length,
+ stdout: { readFrom: () => read },
+ stderr: { readFrom: () => read },
+ done: Promise.resolve({
+ exitCode: killed ? null : 0,
+ signal: null,
+ stdout: { text: 'ok', truncated: false },
+ stderr: { text: '', truncated: false },
+ }),
+ kill: () => { killed = true },
+ }
+ }
+}
+
+describe('ProcessManager seam', () => {
+ it('a concrete subclass registers as ctx.processes and serves the abstract API', async () => {
+ const ctx = new Context()
+ await ctx.plugin(StubProcessManager)
+ const handle = ctx.processes.spawn({
+ argv: ['true'],
+ cwd: '/stub',
+ stdoutMaxBytes: 1,
+ stderrMaxBytes: 1,
+ maxSpillBytes: 1,
+ graceMs: 1,
+ })
+ expect(handle.pid).toBe(1)
+ expect(handle.stdout.readFrom(0)).toEqual({ text: '', nextOffset: 0, lossy: false })
+ handle.kill()
+ const outcome = await handle.done
+ expect(outcome.stdout.text).toBe('ok')
+ })
+
+ it('loading a second implementation throws (one processes service per context — cordis standard)', async () => {
+ const ctx = new Context()
+ await ctx.plugin(StubProcessManager)
+ class SecondManager extends StubProcessManager {}
+ await expect(ctx.plugin(SecondManager)).rejects.toThrow(/service "processes" has been registered/)
+ })
+})
diff --git a/packages/process/process/tsconfig.json b/packages/process/process/tsconfig.json
new file mode 100644
index 0000000000..9966c8ca8a
--- /dev/null
+++ b/packages/process/process/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "lib/types"
+ },
+ "include": [
+ "src"
+ ],
+ "references": [
+ {
+ "path": "../../../vendor/cosmokit"
+ },
+ {
+ "path": "../../../vendor/cordis"
+ },
+ {
+ "path": "../../support/invariants"
+ }
+ ]
+}
diff --git a/packages/sdk/helper/src/features/builtin/index.ts b/packages/sdk/helper/src/features/builtin/index.ts
index 48b50977f8..a6873b75dc 100644
--- a/packages/sdk/helper/src/features/builtin/index.ts
+++ b/packages/sdk/helper/src/features/builtin/index.ts
@@ -32,7 +32,10 @@ export function createBuiltinRegistry(profile: ProjectProfile): FeatureRegistry
summary: 'Command execution',
mode: 'exclusive',
required: true,
- baseResources: [{ kind: 'npm-cordis-config-entry', id: 'tool-bash', package: '@deepseek-ai/dsh-tool-bash' }],
+ baseResources: [
+ { kind: 'npm-cordis-config-entry', id: 'processes', package: '@deepseek-ai/dsh-process-local' },
+ { kind: 'npm-cordis-config-entry', id: 'tool-bash', package: '@deepseek-ai/dsh-tool-bash' },
+ ],
options: [
{
id: 'local',
diff --git a/packages/subagent/subagent-spawn/package.json b/packages/subagent/subagent-spawn/package.json
index f429025a5d..6ab0d4f45f 100644
--- a/packages/subagent/subagent-spawn/package.json
+++ b/packages/subagent/subagent-spawn/package.json
@@ -41,6 +41,7 @@
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
+ "@deepseek-ai/dsh-process-local": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
diff --git a/packages/subagent/subagent-spawn/tests/harness.ts b/packages/subagent/subagent-spawn/tests/harness.ts
index 3b9342a90e..3bebb612c4 100644
--- a/packages/subagent/subagent-spawn/tests/harness.ts
+++ b/packages/subagent/subagent-spawn/tests/harness.ts
@@ -3,6 +3,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
+import LocalProcessManager from '@deepseek-ai/dsh-process-local'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import SubagentService from '@deepseek-ai/dsh-subagent'
@@ -27,6 +28,7 @@ export async function spawnHarness(workdir: string): Promise {
})
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LlmDeepSeek)
+ await ctx.plugin(LocalProcessManager)
await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 })
await ctx.plugin(ToolBash)
await ctx.plugin(SubagentService)
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5ed728d64d..c0471ffc91 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -182,6 +182,9 @@ importers:
'@deepseek-ai/dsh-paths':
specifier: workspace:^
version: link:../../packages/util/paths
+ '@deepseek-ai/dsh-process-local':
+ specifier: workspace:^
+ version: link:../../packages/process/process-local
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../packages/core/session
@@ -424,6 +427,9 @@ importers:
'@deepseek-ai/dsh-plan-mode':
specifier: workspace:*
version: link:../packages/plan/plan-mode
+ '@deepseek-ai/dsh-process-local':
+ specifier: workspace:*
+ version: link:../packages/process/process-local
'@deepseek-ai/dsh-pty':
specifier: workspace:*
version: link:../packages/pty/pty
@@ -585,6 +591,9 @@ importers:
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../support/invariants
+ '@deepseek-ai/dsh-process':
+ specifier: workspace:^
+ version: link:../../process/process
'@deepseek-ai/dsh-sandbox':
specifier: workspace:^
version: link:../../sandbox/sandbox
@@ -604,6 +613,12 @@ importers:
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../support/invariants
+ '@deepseek-ai/dsh-process':
+ specifier: workspace:^
+ version: link:../../process/process
+ '@deepseek-ai/dsh-process-local':
+ specifier: workspace:^
+ version: link:../../process/process-local
'@deepseek-ai/dsh-timeout':
specifier: workspace:^
version: link:../../util/timeout
@@ -622,6 +637,9 @@ importers:
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../support/invariants
+ '@deepseek-ai/dsh-process-local':
+ specifier: workspace:^
+ version: link:../../process/process-local
'@deepseek-ai/dsh-sandbox':
specifier: workspace:^
version: link:../../sandbox/sandbox
@@ -668,6 +686,9 @@ importers:
'@deepseek-ai/dsh-paths':
specifier: workspace:^
version: link:../../util/paths
+ '@deepseek-ai/dsh-process-local':
+ specifier: workspace:^
+ version: link:../../process/process-local
'@deepseek-ai/dsh-sandbox':
specifier: workspace:^
version: link:../../sandbox/sandbox
@@ -1980,6 +2001,9 @@ importers:
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
+ '@deepseek-ai/dsh-process-local':
+ specifier: workspace:^
+ version: link:../../process/process-local
'@deepseek-ai/dsh-retention':
specifier: workspace:^
version: link:../../util/retention
@@ -2200,6 +2224,9 @@ importers:
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
+ '@deepseek-ai/dsh-process-local':
+ specifier: workspace:^
+ version: link:../../process/process-local
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
@@ -2249,6 +2276,9 @@ importers:
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
+ '@deepseek-ai/dsh-process-local':
+ specifier: workspace:^
+ version: link:../../process/process-local
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
@@ -2613,6 +2643,27 @@ 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/process/process:
+ 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/process/process-local:
+ devDependencies:
+ '@deepseek-ai/dsh-invariants':
+ specifier: workspace:^
+ version: link:../../support/invariants
+ '@deepseek-ai/dsh-process':
+ specifier: workspace:^
+ version: link:../process
+ 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/pty/pty:
devDependencies:
'@deepseek-ai/dsh-agent':
@@ -3569,6 +3620,9 @@ importers:
'@deepseek-ai/dsh-llm-deepseek':
specifier: workspace:^
version: link:../../llm/llm-deepseek
+ '@deepseek-ai/dsh-process-local':
+ specifier: workspace:^
+ version: link:../../process/process-local
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
@@ -4582,6 +4636,12 @@ importers:
'@deepseek-ai/dsh-plan-mode':
specifier: workspace:^
version: link:../../packages/plan/plan-mode
+ '@deepseek-ai/dsh-process':
+ specifier: workspace:^
+ version: link:../../packages/process/process
+ '@deepseek-ai/dsh-process-local':
+ specifier: workspace:^
+ version: link:../../packages/process/process-local
'@deepseek-ai/dsh-repeat-tool-guard':
specifier: workspace:^
version: link:../../packages/guard/repeat-tool-guard
diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json
index abf943793d..6fca02ccf8 100644
--- a/python/sdk-runtime/package.json
+++ b/python/sdk-runtime/package.json
@@ -40,6 +40,8 @@
"@deepseek-ai/dsh-llm-pi-ai": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-plan-mode": "workspace:^",
+ "@deepseek-ai/dsh-process": "workspace:^",
+ "@deepseek-ai/dsh-process-local": "workspace:^",
"@deepseek-ai/dsh-permission": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-repeat-tool-guard": "workspace:^",
diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml b/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml
index 824aa03e7b..b59b94a257 100644
--- a/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml
+++ b/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml
@@ -33,6 +33,10 @@
name: '@deepseek-ai/dsh-session-checkpoint-policy'
# Local bash executor; $DSH_CWD wins over the process cwd.
+# Managed child-process groups for the bash executor (spawn/kill/output plumbing).
+- id: processes
+ name: '@deepseek-ai/dsh-process-local'
+
- id: bash
name: '@deepseek-ai/dsh-bash-local'
config:
diff --git a/python/sdk/tests/test_bundled_runtime.py b/python/sdk/tests/test_bundled_runtime.py
index 6ff14fa266..755e65e00a 100644
--- a/python/sdk/tests/test_bundled_runtime.py
+++ b/python/sdk/tests/test_bundled_runtime.py
@@ -30,6 +30,8 @@ _CORDIS_YML = """\
root: './sessions'
- id: session-checkpoints
name: '@deepseek-ai/dsh-session-checkpoint-policy'
+- id: processes
+ name: '@deepseek-ai/dsh-process-local'
- id: bash
name: '@deepseek-ai/dsh-bash-local'
config:
diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json
index c2fee63c70..a8e259d16b 100644
--- a/scripts/doc-budgets.manifest.json
+++ b/scripts/doc-budgets.manifest.json
@@ -7,5 +7,5 @@
"docs/testing.md": 1100,
"examples/AGENTS.md": 310,
"packages/AGENTS.md": 660,
- "packages/README.md": 790
+ "packages/README.md": 810
}
diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts
index b53d1eaa12..07adb0ba0c 100644
--- a/scripts/gen-cordis-catalog.ts
+++ b/scripts/gen-cordis-catalog.ts
@@ -64,7 +64,12 @@ export const LINK_MAP: Record = {
BashExecSpec: 'bash.md',
BashProcess: 'bash.md',
BashRunResult: 'bash.md',
- DshEnvironment: 'bash.md',
+ DshEnvironment: 'process.md',
+ ProcessHandle: 'process.md',
+ ProcessOutcome: 'process.md',
+ ProcessOutputRead: 'process.md',
+ ProcessOutputReader: 'process.md',
+ ProcessSpawnSpec: 'process.md',
CodeRunRequest: 'code-runtime.md',
CodeRunResult: 'code-runtime.md',
CompactionResult: 'compaction.md',
diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts
index 2551f95df9..05cec1dbad 100644
--- a/scripts/gen-doc-graphs.ts
+++ b/scripts/gen-doc-graphs.ts
@@ -59,6 +59,7 @@ const GROUP_ORDER = [
'llm',
'core',
'goal',
+ 'process',
'bash',
'pty',
'sandbox',
@@ -264,6 +265,15 @@ const SERVICE_ROLES: ServiceRole[] = [
mode: 'core',
note: 'Folds revisioned objective state from the session log and keeps live continuation activation process-local.',
},
+ {
+ key: 'processes',
+ pkg: 'process',
+ title: 'Process manager seam',
+ mode: 'seam',
+ implementations: ['process-local'],
+ consumers: ['bash-local', 'bash-sandbox'],
+ note: 'The bash executors spawn their process groups through ctx.processes; the manager owns group lifetime, bounded spill-backed output, and kill escalation.',
+ },
{
key: 'bash',
pkg: 'bash',
diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts
index 45aa58d74e..5d787814eb 100644
--- a/scripts/gen-tool-catalog.ts
+++ b/scripts/gen-tool-catalog.ts
@@ -19,6 +19,7 @@ import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools
import { BashExecutor } from '@deepseek-ai/dsh-bash'
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
import LocalBashExecutor from '@deepseek-ai/dsh-bash-local'
+import LocalProcessManager from '@deepseek-ai/dsh-process-local'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import PlanModeService from '@deepseek-ai/dsh-plan-mode'
@@ -197,6 +198,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
requires: ['ctx.tools', 'ctx.bash', 'ctx.tasks at call time for run_in_background'],
writes: ['tool/call', 'tool/result'],
async mount(ctx) {
+ await ctx.plugin(LocalProcessManager)
await ctx.plugin(LocalBashExecutor)
await ctx.plugin(ToolBash)
},
diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json
index 0310306bfa..629cfe4038 100644
--- a/scripts/type-equiv.manifest.json
+++ b/scripts/type-equiv.manifest.json
@@ -729,16 +729,6 @@
"symbol": "ApprovalRequest",
"source": "packages/ui/user-approval/src/index.ts"
},
- {
- "doc": "docs/core-data-structures/bash.md",
- "symbol": "DshEnvironmentKey",
- "source": "packages/bash/bash/src/types.ts"
- },
- {
- "doc": "docs/core-data-structures/bash.md",
- "symbol": "DshEnvironment",
- "source": "packages/bash/bash/src/types.ts"
- },
{
"doc": "docs/core-data-structures/bash.md",
"symbol": "BashExecRequest",
@@ -759,11 +749,6 @@
"symbol": "BashSandboxInfo",
"source": "packages/bash/bash/src/types.ts"
},
- {
- "doc": "docs/core-data-structures/bash.md",
- "symbol": "CollectedOutput",
- "source": "packages/bash/bash/src/types.ts"
- },
{
"doc": "docs/core-data-structures/bash.md",
"symbol": "BashProcess",
@@ -1867,16 +1852,6 @@
"symbol": "ApprovalRequest",
"source": "packages/ui/user-approval/src/index.ts"
},
- {
- "doc": "docs/core-data-structures/bash.zh.md",
- "symbol": "DshEnvironmentKey",
- "source": "packages/bash/bash/src/types.ts"
- },
- {
- "doc": "docs/core-data-structures/bash.zh.md",
- "symbol": "DshEnvironment",
- "source": "packages/bash/bash/src/types.ts"
- },
{
"doc": "docs/core-data-structures/bash.zh.md",
"symbol": "BashExecRequest",
@@ -1897,11 +1872,6 @@
"symbol": "BashSandboxInfo",
"source": "packages/bash/bash/src/types.ts"
},
- {
- "doc": "docs/core-data-structures/bash.zh.md",
- "symbol": "CollectedOutput",
- "source": "packages/bash/bash/src/types.ts"
- },
{
"doc": "docs/core-data-structures/bash.zh.md",
"symbol": "BashProcess",
@@ -2191,6 +2161,31 @@
"doc": "docs/core-data-structures/workflow.zh.md",
"symbol": "WorkflowRun",
"source": "packages/workflow/workflow/src/types.ts"
+ },
+ {
+ "doc": "docs/core-data-structures/process.md",
+ "symbol": "ProcessSpawnSpec",
+ "source": "packages/process/process/src/types.ts"
+ },
+ {
+ "doc": "docs/core-data-structures/process.md",
+ "symbol": "ProcessHandle",
+ "source": "packages/process/process/src/types.ts"
+ },
+ {
+ "doc": "docs/core-data-structures/process.md",
+ "symbol": "ProcessOutputReader",
+ "source": "packages/process/process/src/types.ts"
+ },
+ {
+ "doc": "docs/core-data-structures/process.md",
+ "symbol": "ProcessOutputRead",
+ "source": "packages/process/process/src/types.ts"
+ },
+ {
+ "doc": "docs/core-data-structures/process.md",
+ "symbol": "ProcessOutcome",
+ "source": "packages/process/process/src/types.ts"
}
]
}
diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts
index 16803b157f..dc284e9b21 100644
--- a/scripts/verify-package-readme-model-experience.ts
+++ b/scripts/verify-package-readme-model-experience.ts
@@ -72,6 +72,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = {
'packages/llm/token-meter': { kind: 'indirect', reason: 'The measurement service leaves model-visible changes to its consumers.' },
'packages/lsp/lsp': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-lsp.' },
'packages/lsp/lsp-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-lsp.' },
+ 'packages/process/process': { kind: 'indirect', reason: 'The seam delegates all model rendering to consumer seams such as the bash executor family.' },
+ 'packages/process/process-local': { kind: 'indirect', reason: 'The manager backend delegates model rendering to consumer seams such as the bash executor family.' },
'packages/sandbox/sandbox-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-bash-sandbox and dsh-tool-bash.' },
'packages/sandbox/sandbox-policy': { kind: 'indirect', reason: 'The policy service holds the mode dsh-tool-bash and dsh-tool-fs render in their denial markers.' },
'packages/sdk/create-sdk': { kind: 'indirect', reason: 'The initializer only writes project files; selected runtime plugins provide the generated project model surface.' },
diff --git a/tsconfig.base.json b/tsconfig.base.json
index a449a34c4e..2f73278c55 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -54,6 +54,7 @@
"./packages/prompt/*/src/invariant.ts",
"./packages/llm/*/src/invariant.ts",
"./packages/bash/*/src/invariant.ts",
+ "./packages/process/*/src/invariant.ts",
"./packages/code-runtime/*/src/invariant.ts",
"./packages/fs/*/src/invariant.ts",
"./packages/skill/*/src/invariant.ts",
@@ -123,6 +124,7 @@
"./packages/llm/*/src",
"./packages/bash/*/src",
"./packages/pty/*/src",
+ "./packages/process/*/src",
"./packages/code-runtime/*/src",
"./packages/fs/*/src",
"./packages/lsp/*/src",
diff --git a/tsconfig.host.json b/tsconfig.host.json
index 1aab67964a..c4bdc74fe1 100644
--- a/tsconfig.host.json
+++ b/tsconfig.host.json
@@ -81,6 +81,8 @@
{ "path": "./packages/llm/llm-retry" },
{ "path": "./packages/examples/agent-spine-demo" },
{ "path": "./packages/examples/cli-demo" },
+ { "path": "./packages/process/process" },
+ { "path": "./packages/process/process-local" },
{ "path": "./packages/bash/bash" },
{ "path": "./packages/pty/pty" },
{ "path": "./packages/pty/pty-local" },
diff --git a/vitest.config.ts b/vitest.config.ts
index deb2460f11..0f2b25f627 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -40,7 +40,7 @@ const testIncludes = [
// that worker threads cannot isolate reliably under aggregate gate contention.
// Keep the narrow exception in forks while the rest of the inventory avoids per-file processes.
const processBoundTests = [
- 'packages/bash/bash-local/tests/run.spec.ts',
+ 'packages/process/process-local/tests/spawn.spec.ts',
'packages/context/time-context/tests/time-context.spec.ts',
'packages/llm/llm-pi-ai/tests/adapter.spec.ts',
'packages/ui/app-boot/tests/app-boot.spec.ts',