refactor(process): split the process manager out of the bash executor

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).
This commit is contained in:
Tianyi Cui
2026-07-26 06:59:01 +08:00
parent 71c564d801
commit 0d6bfd8856
89 changed files with 1331 additions and 365 deletions

View File

@@ -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.)

View File

@@ -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 在本次设计中保持无超时——见「后果」。)

View File

@@ -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.

View File

@@ -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'

View File

@@ -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:^",

View File

@@ -82,10 +82,13 @@ flowchart LR
pkg_agent_spine_demo["agent-spine-demo"]
pkg_goal["goal"]
svc_goals["ctx.goals<br/>Same-session goal domain"]
pkg_bash["bash"]
svc_bash["ctx.bash<br/>Bash executor seam"]
pkg_process["process"]
svc_processes["ctx.processes<br/>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<br/>Bash executor seam"]
svc_bashEnv["ctx.bashEnv<br/>Managed bash environment registry"]
pkg_pty["pty"]
svc_pty["ctx.pty<br/>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. |

View File

@@ -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))

View File

@@ -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.

View File

@@ -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<Record<DshEnvironmentKey, string>>
```
`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`

View File

@@ -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<Record<DshEnvironmentKey, string>>
```
`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`

View File

@@ -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<string, string> | 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<ProcessOutcome>
/** 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.

View File

@@ -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) |

View File

@@ -14,6 +14,8 @@ flowchart LR
cfg --> plugin_acp_sandbox
plugin_acp_sandbox_policy["sandbox-policy<br/>@deepseek-ai/dsh-sandbox-policy"]
cfg --> plugin_acp_sandbox_policy
plugin_acp_processes["processes<br/>@deepseek-ai/dsh-process-local"]
cfg --> plugin_acp_processes
plugin_acp_bash["bash<br/>@deepseek-ai/dsh-bash-sandbox"]
cfg --> plugin_acp_bash
plugin_acp_approval["approval<br/>@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` |

View File

@@ -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:

View File

@@ -12,6 +12,8 @@ flowchart LR
cfg --> plugin_cordis_hmr
plugin_cordis_llm_deepseek["llm-deepseek<br/>@deepseek-ai/dsh-llm-deepseek"]
cfg --> plugin_cordis_llm_deepseek
plugin_cordis_processes["processes<br/>@deepseek-ai/dsh-process-local"]
cfg --> plugin_cordis_processes
plugin_cordis_bash["bash<br/>@deepseek-ai/dsh-bash-local"]
cfg --> plugin_cordis_bash
plugin_cordis_fs_local["fs-local<br/>@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` |

View File

@@ -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:

View File

@@ -10,6 +10,8 @@ flowchart LR
cfg["examples/headless-agent<br/>cordis.yml"]
plugin_headless_llm_deepseek["llm-deepseek<br/>@deepseek-ai/dsh-llm-deepseek"]
cfg --> plugin_headless_llm_deepseek
plugin_headless_processes["processes<br/>@deepseek-ai/dsh-process-local"]
cfg --> plugin_headless_processes
plugin_headless_bash["bash<br/>@deepseek-ai/dsh-bash-local"]
cfg --> plugin_headless_bash
plugin_headless_cli_agent["cli-agent<br/>@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` |

View File

@@ -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:

View File

@@ -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:

View File

@@ -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<Context> {
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<Context> {
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

View File

@@ -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'

View File

@@ -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'

View File

@@ -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)

View File

@@ -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:

View File

@@ -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:*",

View File

@@ -12,6 +12,8 @@ flowchart LR
cfg --> plugin_tui_hmr
plugin_tui_llm_deepseek["llm-deepseek<br/>@deepseek-ai/dsh-llm-deepseek"]
cfg --> plugin_tui_llm_deepseek
plugin_tui_processes["processes<br/>@deepseek-ai/dsh-process-local"]
cfg --> plugin_tui_processes
plugin_tui_bash["bash<br/>@deepseek-ai/dsh-bash-local"]
cfg --> plugin_tui_bash
plugin_tui_tui_agent["tui-agent<br/>@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` |

View File

@@ -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:

View File

@@ -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'

View File

@@ -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)

View File

@@ -11,6 +11,7 @@ Packages live at `packages/<group>/<pkg>/`; 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 |

View File

@@ -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`) |

View File

@@ -1,8 +1,8 @@
# @deepseek-ai/dsh-bash-local
Local-subprocess implementation of the `@deepseek-ai/dsh-bash` executor seam: `LocalBashExecutor` spawns `bash -c <command>` 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 <command>` 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.

View File

@@ -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"
}

View File

@@ -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 SIGTERMSIGKILL 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<Config> = 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<BashProcess, RunningBash>()
/** 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<void>[] = []
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<BashRunResult> {
// 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<BashRunResult> {
// 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
}

View File

@@ -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<typeof LocalBashExecutor>[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')
})
})

View File

@@ -26,6 +26,9 @@
{
"path": "../../bash/bash"
},
{
"path": "../../process/process"
},
{
"path": "../../support/invariants"
}

View File

@@ -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:^",

View File

@@ -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.
*/

View File

@@ -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 }
}

View File

@@ -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"
}

View File

@@ -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<Record<DshEnvironmentKey, string>>
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. */

View File

@@ -14,6 +14,9 @@
{
"path": "../../../vendor/cordis"
},
{
"path": "../../process/process"
},
{
"path": "../../sandbox/sandbox"
},

View File

@@ -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:^",

View File

@@ -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)

View File

@@ -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 })

View File

@@ -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<ProcessOutcome>;\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<string, string> | undefined;\n dshEnv?: DshEnvironment | undefined;\n}',
},
{
name: 'PromptAssembly',
declaration: 'export interface PromptAssembly {\n sections: AssembledSection[];\n tools: ToolSchema[];\n variables: Record<string, string | undefined>;\n}',

View File

@@ -94,6 +94,8 @@ async function makeConsumer(): Promise<string> {
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',

View File

@@ -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

View File

@@ -80,6 +80,8 @@ async function makeConsumer(): Promise<string> {
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',

View File

@@ -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:^",

View File

@@ -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)
})

View File

@@ -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:^",

View File

@@ -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()

View File

@@ -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([]))

View File

@@ -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:^",

View File

@@ -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<Context> {
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([]))

View File

@@ -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)

View File

@@ -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.

View File

@@ -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.

View File

@@ -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"
}
}

View File

@@ -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<ProcessHandle>()
/** 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<unknown>[] = []
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

View File

@@ -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 */

View File

@@ -1,8 +1,9 @@
/**
* Process plumbing for the local bash executor: detached process-group spawn,
* tail-keep output with spill files, and SIGTERMSIGKILL 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 SIGTERMSIGKILL 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<string, string> | 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 SIGTERMgraceSIGKILL 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<SpawnOutcome>
/** 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<Writable | null, Readable, Readable> = 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<SpawnOutcome>((resolve, reject) => {
const done = new Promise<ProcessOutcome>((resolve, reject) => {
let settled = false
let pipeDrainTimer: NodeJS.Timeout | undefined
const settle = (exitCode: number | null, signal: NodeJS.Signals | null): void => {

View File

@@ -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> = {}): 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/)
})
})

View File

@@ -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<Parameters<typeof runBash>[0]> = {}) {
function spec(command: string, overrides: Partial<Parameters<typeof spawnProcess>[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<void> {
throw new Error(`pid ${pid} still alive after ${timeoutMs}ms`)
}
async function waitForStdout(running: RunningBash, expected: string, timeoutMs = 5_000): Promise<void> {
async function waitForStdout(running: ProcessHandle, expected: string, timeoutMs = 5_000): Promise<void> {
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<number>
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')

View File

@@ -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"
}
]
}

View File

@@ -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.

View File

@@ -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"
}
}

View File

@@ -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

View File

@@ -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))

View File

@@ -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<Record<DshEnvironmentKey, string>>
/** 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<string, string> | 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<ProcessOutcome>
/** Begin SIGTERM→grace→SIGKILL on the process group. Idempotent. */
kill(): void
}

View File

@@ -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/)
})
})

View File

@@ -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"
}
]
}

View File

@@ -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',

View File

@@ -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:^",

View File

@@ -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<Context> {
})
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)

60
pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -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:^",

View File

@@ -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:

View File

@@ -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:

View File

@@ -7,5 +7,5 @@
"docs/testing.md": 1100,
"examples/AGENTS.md": 310,
"packages/AGENTS.md": 660,
"packages/README.md": 790
"packages/README.md": 810
}

View File

@@ -64,7 +64,12 @@ export const LINK_MAP: Record<string, string> = {
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',

View File

@@ -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',

View File

@@ -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)
},

View File

@@ -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"
}
]
}

View File

@@ -72,6 +72,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'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.' },

View File

@@ -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",

View File

@@ -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" },

View File

@@ -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',