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