refactor(subprocess): rename the process seam to subprocess and address review

Review feedback (tianyicui): 'process' is a poor service name. The family is
now packages/subprocess/ — @deepseek-ai/dsh-subprocess (ctx.subprocess,
abstract SubprocessService, Subprocess* vocabulary) and
@deepseek-ai/dsh-subprocess-local (LocalSubprocessService) — renamed
throughout code, compositions, docs (en+zh, pairs re-recorded), catalogs,
and gates. 'subprocess' is the precise term for managed OS children (the
Python-stdlib sense), avoids colliding with Node's global process object,
and reads as one system beside dsh-subagent-subprocess.

ds-review-bot findings addressed:
- kill() on a settled handle is now a no-op (no signal to a possibly-reused
  pgid, no referenced grace timer delaying exit); pinned by a spy test.
- The moved DshEnvironmentKey/DshEnvironment/CollectedOutput types get
  drift-checked type-equiv blocks on the new subprocess.md page, restoring
  their manifest registration.
- subprocess.md is registered in the core.md sub-page index (en+zh).
This commit is contained in:
Tianyi Cui
2026-07-26 12:43:14 +08:00
parent 8f75565f03
commit fc566119a7
108 changed files with 587 additions and 557 deletions

View File

@@ -11,7 +11,7 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
| [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | Product — stable surface |
| [`goal/`](goal/README.md) | Persisted same-session goal state and lifecycle | Product — stable surface |
| [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface |
| [`process/`](process/README.md) | Child-process manager capability family: spawn seam + local process-group implementation | Product — stable surface |
| [`subprocess/`](subprocess/README.md) | Subprocess capability family: spawn seam + local process-group implementation | Product — stable surface |
| [`bash/`](bash/README.md) | Bash capability family: executor seam, local impl, model-facing tool | Product — stable surface |
| [`pty/`](pty/README.md) | Persistent PTY capability family: owner-scoped sessions, local implementation, and model-facing tools | Product — stable surface |
| [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the runtime seam for model-written programs + a worker-thread backend | Product — stable surface |

View File

@@ -4,8 +4,8 @@ The canonical three-package capability seam (see [capability seams](../../.agent
| Package | Role | ctx key |
|---|---|---|
| `bash/` | Abstract bash executor seam (interface + vocabulary; sandbox result facts carry the [`sandbox/`](../sandbox/README.md) seam's mode/enforcement vocabulary, 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/` | 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 [`subprocess/`](../subprocess/README.md) seam) | `ctx.bash` |
| `bash-local/` | Local `BashExecutor` implementation over the [`subprocess/`](../subprocess/README.md) service (command defaulting, deadlines, terminal env, background-read merge) | (registers `ctx.bash`) |
| `bash-sandbox/` | Sandbox-consuming `BashExecutor` (wraps every command argv via `ctx.sandbox`, stamps denial/enforcement facts; extends `bash-local`'s mechanics) | (registers `ctx.bash`) |
| `tool-bash/` | Model-facing `bash` schema; background processes register with the generic [`tasks/`](../tasks/README.md) runtime | (registers on `ctx.tools`) |

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-bash-local
Local implementation of the `@deepseek-ai/dsh-bash` executor seam over the [`@deepseek-ai/dsh-process`](../../process/process/README.md) manager: `LocalBashExecutor` spawns `bash -c <command>` per call as a managed process group through `ctx.processes`, and owns everything bash-shaped — command defaulting and caps, timeout/cancel classification, the model-friendly terminal environment, and the model-facing stdout/stderr merge for background reads. Group mechanics (bounded spill-backed output, credential scrub, kill escalation, disposal) are the process manager's.
Local implementation of the `@deepseek-ai/dsh-bash` executor seam over the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) service: `LocalBashExecutor` spawns `bash -c <command>` per call as a managed process group through `ctx.subprocess`, 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 subprocess service's.
The package root exports the default and named `LocalBashExecutor` plugin plus its `Config`.
@@ -23,10 +23,10 @@ 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/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`.
- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service 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-subprocess-local`](../../subprocess/subprocess-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-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 service'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 service's offset-based stdout/stderr reads into one marked-section delta with a consuming cursor. A still-running process belongs to the subprocess service, so it survives executor reloads and dies (killed and joined) with the service'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,7 +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 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.
- **POSIX-only** — the `bash` binary is hardcoded, and the underlying service's group semantics are POSIX; Windows is unsupported.
- **A background spawn-failure note is single-delivery** — the subprocess service 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.
Scrub-heuristic and spill-retention caveats live with [`dsh-process-local`](../../process/process-local/README.md), which owns those mechanics.
Scrub-heuristic and spill-retention caveats live with [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md), which owns those mechanics.

View File

@@ -29,7 +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-subprocess": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
@@ -39,8 +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-subprocess": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"cordis": "^4.0.0-rc.7"
}

View File

@@ -1,7 +1,7 @@
/**
* Local implementation of the bash executor seam over the process-manager
* Local implementation of the bash executor seam over the subprocess
* seam. Each command runs as `bash -c` in a managed process group spawned
* through `ctx.processes`; this executor owns command defaulting, deadlines
* through `ctx.subprocess`; 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.
@@ -12,7 +12,7 @@ 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 type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
/**
@@ -20,7 +20,7 @@ import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
* 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.
* wins; the subprocess service applies its credential scrub independently.
*/
export const ENV_OVERRIDES = {
NO_COLOR: '1',
@@ -61,14 +61,14 @@ function assertPositiveFinite(name: string, value: number): void {
}
/**
* Local bash executor over `ctx.processes`. Bounded output, spill files, and
* process-group SIGTERM→SIGKILL escalation are the process manager's
* Local bash executor over `ctx.subprocess`. Bounded output, spill files, and
* process-group SIGTERM→SIGKILL escalation are the subprocess service'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 inject = ['subprocess']
static Config: z<Config> = z.object({
cwd: z.string(),
@@ -116,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. The process manager owns the scrub and merge order.
// no config default. The subprocess service owns the scrub and merge order.
...request.stdin !== undefined ? { stdin: request.stdin } : {},
...request.env !== undefined ? { env: request.env } : {},
...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {},
@@ -129,7 +129,7 @@ export class LocalBashExecutor extends BashExecutor {
/** 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 {
private spawnSpec(spec: BashExecSpec, stdoutMaxBytes: number, signal: AbortSignal | undefined): SubprocessSpawnSpec {
return {
argv: ['bash', '-c', spec.command],
cwd: spec.workdir,
@@ -147,7 +147,7 @@ export class LocalBashExecutor extends BashExecutor {
async run(spec: BashExecSpec): Promise<BashRunResult> {
// One deadline combines timeout and upstream cancellation; disposal clears its timer.
using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT')
const outcome = await this.ctx.processes.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal)).done
const outcome = await this.ctx.subprocess.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
@@ -156,9 +156,9 @@ export class LocalBashExecutor extends BashExecutor {
start(spec: BashExecSpec): BashProcess {
// Background runs ignore timeoutMs; callers stop them through kill() or spec.signal.
const running = this.ctx.processes.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal))
const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal))
// A spawn failure produces no process output, so the manager has nothing
// A spawn failure produces no process output, so the subprocess service has nothing
// to buffer; the note is delivered exactly once through the read path.
let spawnFailureNote: string | undefined
const consumeSpawnFailure = (): string => {

View File

@@ -4,15 +4,15 @@ 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 LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import type { BashProcess } from '@deepseek-ai/dsh-bash'
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-exec-spec-'))
async function setup(config: ConstructorParameters<typeof LocalBashExecutor>[1] = {}) {
const ctx = new Context()
await ctx.plugin(LocalProcessManager)
;(ctx.processes as LocalProcessManager).internals = { spillDir }
await ctx.plugin(LocalSubprocessService)
;(ctx.subprocess as LocalSubprocessService).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
@@ -295,11 +295,11 @@ describe('LocalBashExecutor.start (background process handles)', () => {
})
})
describe('process lifecycle ownership (the manager, not the executor)', () => {
it('a background process survives executor-fiber disposal and dies with the process manager', async () => {
describe('process lifecycle ownership (the subprocess service, not the executor)', () => {
it('a background process survives executor-fiber disposal and dies with the subprocess service', async () => {
const ctx = new Context()
const managerFiber = await ctx.plugin(LocalProcessManager)
;(ctx.processes as LocalProcessManager).internals = { spillDir }
const managerFiber = await ctx.plugin(LocalSubprocessService)
;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
const executorFiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 })
const bash = ctx.bash as LocalBashExecutor
@@ -316,17 +316,17 @@ describe('process lifecycle ownership (the manager, not the executor)', () => {
expect(proc.status).toBe('running')
expect(() => process.kill(pid, 0)).not.toThrow()
// Manager disposal kills the group and AWAITS its exit (no orphans).
// Service disposal kills the group and AWAITS its exit (no orphans).
await managerFiber.dispose()
expect(() => process.kill(pid, 0)).toThrow()
await proc.done
expect(proc.status).toBe('killed')
})
it('manager disposal escalates to SIGKILL for TERM-trapping children and settles handles', async () => {
it('service disposal escalates to SIGKILL for TERM-trapping children and settles handles', async () => {
const ctx = new Context()
const managerFiber = await ctx.plugin(LocalProcessManager)
;(ctx.processes as LocalProcessManager).internals = { spillDir }
const managerFiber = await ctx.plugin(LocalSubprocessService)
;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
await ctx.plugin(LocalBashExecutor, { graceMs: 200 })
const bash = ctx.bash as LocalBashExecutor

View File

@@ -27,7 +27,7 @@
"path": "../../bash/bash"
},
{
"path": "../../process/process"
"path": "../../subprocess/subprocess"
},
{
"path": "../../support/invariants"

View File

@@ -38,7 +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-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",

View File

@@ -34,7 +34,7 @@ export type Config = LocalConfig
* mode; `result.sandbox` reports the mode and enforcement actually used.
*/
export class SandboxBashExecutor extends LocalBashExecutor {
static override inject = ['processes', 'sandbox', 'sandboxPolicy']
static override inject = ['subprocess', '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` the process manager spawns
* the inherited spawn path runs (the outer `bash -c` the subprocess service spawns
* `exec`s into the runner, so no extra shell lingers). Provider errors
* (fail-closed `SANDBOX_UNAVAILABLE`) propagate to the caller unchanged.
*/

View File

@@ -9,7 +9,7 @@ import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import { bwrapProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts'
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
import LocalProcessManager from '@deepseek-ai/dsh-process-local'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
/**
* Keyless integration of the real provider and executor through public run/start paths. With
@@ -43,7 +43,7 @@ async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-w
ctx = new Context()
await ctx.plugin(LocalSandboxProvider, {})
await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace })
await ctx.plugin(LocalProcessManager)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 })
return ctx.bash as SandboxBashExecutor
}

View File

@@ -9,7 +9,7 @@ import { launcherPath } from 'node-addon-landlock-run'
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
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 LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
/**
* KEYLESS consumer-integration proof: the REAL `LocalSandboxProvider` (bwrap
@@ -48,7 +48,7 @@ async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-w
await ctx.plugin(LocalSandboxProvider, {})
;(ctx.sandbox as LocalSandboxProvider).internals = { probeBwrap: () => false }
await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace })
await ctx.plugin(LocalProcessManager)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 })
return ctx.bash as SandboxBashExecutor
}

View File

@@ -15,7 +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 LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import { classifyDenial, classifyRunnerFailure, shellQuote } from '../src/helpers.ts'
import type { Config } from '@deepseek-ai/dsh-bash-sandbox'
@@ -59,8 +59,8 @@ async function setup(
...mode !== undefined ? { mode } : {},
...workspaceRoot !== undefined ? { workspaceRoot } : {},
})
await ctx.plugin(LocalProcessManager)
;(ctx.processes as LocalProcessManager).internals = { spillDir }
await ctx.plugin(LocalSubprocessService)
;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
await ctx.plugin(SandboxBashExecutor, { graceMs: 200, ...execConfig })
const bash = ctx.bash as SandboxBashExecutor
return { ctx, bash, calls }

View File

@@ -9,7 +9,7 @@ import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import { seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts'
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
import LocalProcessManager from '@deepseek-ai/dsh-process-local'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
/**
* Keyless macOS integration of the real provider and executor through public run/start paths.
@@ -42,7 +42,7 @@ async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-w
await ctx.plugin(LocalSandboxProvider, {})
;(ctx.sandbox as LocalSandboxProvider).internals = { probeBwrap: () => false, probeLandlock: () => 'unusable' }
await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace })
await ctx.plugin(LocalProcessManager)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 })
return ctx.bash as SandboxBashExecutor
}

View File

@@ -28,13 +28,13 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-process": "^0.0.1",
"@deepseek-ai/dsh-subprocess": "^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-subprocess": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"cordis": "^4.0.0-rc.7"
}

View File

@@ -44,8 +44,8 @@ declare module 'cordis' {
* - {@link BashProcess.readOutput} is incremental: consecutive reads never
* repeat output. Lossy reads report truncation and available spill files.
* - A still-running background process is stopped and awaited when its
* owning composition tears down. With the process-manager seam that
* boundary is `ctx.processes` disposal, so a background process survives
* owning composition tears down. With the subprocess seam that
* boundary is `ctx.subprocess` disposal, so a background process survives
* an executor-only reload.
*/
export abstract class BashExecutor extends Service {

View File

@@ -2,16 +2,16 @@
* Execution types for the bash executor seam. Background task semantics belong
* 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
* subprocess 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'
import type { CollectedOutput, DshEnvironment } from '@deepseek-ai/dsh-subprocess'
export { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-process'
export type { CollectedOutput, DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-process'
export { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-subprocess'
export type { CollectedOutput, DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-subprocess'
/**
* Sandbox facts for one run, present iff a sandboxing executor handled it.
@@ -154,7 +154,7 @@ export interface BashProcessRead {
/**
* A background process handle returned by {@link BashExecutor.start}. It is the
* only access path; buffered output remains readable after exit. Composition
* teardown (the process manager's disposal) kills running processes and
* teardown (the subprocess service's disposal) kills running processes and
* awaits {@link done}; an executor-only reload leaves them running.
*/
export interface BashProcess {

View File

@@ -15,7 +15,7 @@
"path": "../../../vendor/cordis"
},
{
"path": "../../process/process"
"path": "../../subprocess/subprocess"
},
{
"path": "../../sandbox/sandbox"

View File

@@ -52,7 +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-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",

View File

@@ -11,7 +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 LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
@@ -30,7 +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(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
await ctx.plugin(ToolBash, dshHome === undefined ? {} : { dshHome })
ctx.llm.registerAdapter(['mock'], adapter)

View File

@@ -17,7 +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 LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import { processOutcome } from '../src/background.ts'
@@ -33,8 +33,8 @@ 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(LocalSubprocessService)
;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 })
await ctx.plugin(ToolBash)
return ctx
@@ -48,8 +48,8 @@ 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(LocalSubprocessService)
;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 })
await ctx.plugin(ToolBash)
return ctx
@@ -278,8 +278,8 @@ 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(LocalSubprocessService)
;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100, graceMs: 200 })
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' })
@@ -387,7 +387,7 @@ describe('bash tool', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(LocalProcessManager)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, {})
const fiber = await ctx.plugin(ToolBash)
expect(ctx.tools.schemas()).toHaveLength(1)
@@ -405,7 +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(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, {})
await new Promise(resolve => setTimeout(resolve, 0))
expect(ctx.tools.schemas()).toHaveLength(1)
@@ -417,7 +417,7 @@ describe('bash tool', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(LocalProcessManager)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, {})
ToolBash.apply(ctx, {})
const schema = ctx.tools.schemas()[0]!
@@ -533,7 +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(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, {})
await ctx.plugin(ToolBash, { enableRunInBackground: false })

View File

@@ -426,16 +426,6 @@ 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.',
@@ -754,6 +744,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
],
},
{
key: 'subprocess',
summary: 'Abstract subprocess service.',
methods: [
{
signature: 'abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle',
jsDoc: '/**\n * Start one managed child process from a fully-specified spec; this seam\n * applies no defaults.\n * @param spec - argv, directory, limits, grace, cancellation, and environment.\n * @returns the live process handle (readers, kill, outcome promise).\n */',
},
],
},
{
key: 'systemPrompt',
summary: 'Registry service for the prompt inputs assembled before each model step.',
@@ -1458,10 +1458,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'CodeRunResult',
declaration: 'export interface CodeRunResult {\n value?: CodeJsonValue;\n logs: string[];\n error?: CodeRunFailure;\n}',
},
{
name: 'CollectedOutput',
declaration: 'export interface CollectedOutput {\n text: string;\n truncated: boolean;\n spillPath?: string;\n}',
},
{
name: 'CommandDefinition',
declaration: 'export interface CommandDefinition {\n readonly name: string;\n readonly description: string;\n readonly input?: CommandInputDescriptor;\n readonly handler: (invocation: CommandInvocation) => CommandResult | Promise<CommandResult>;\n}',
@@ -1558,14 +1554,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'DomainTableSpec',
declaration: 'export interface DomainTableSpec<K extends string = string, V = unknown> {\n readonly valueSchema: ZodType<V>;\n readonly __key?: K;\n}',
},
{
name: 'DshEnvironment',
declaration: 'export type DshEnvironment = Readonly<Record<DshEnvironmentKey, string>>;',
},
{
name: 'DshEnvironmentKey',
declaration: 'export type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}`;',
},
{
name: 'EditGoalRequest',
declaration: 'export interface EditGoalRequest {\n readonly objective?: string;\n readonly maxGoalRounds?: number;\n}',
@@ -1770,26 +1758,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'PresetSpec',
declaration: 'export interface PresetSpec {\n sandbox: SandboxMode;\n approval: ApprovalPolicy;\n name?: string;\n description?: string;\n}',
},
{
name: 'ProcessHandle',
declaration: 'export interface ProcessHandle {\n readonly pid: number;\n readonly stdout: ProcessOutputReader;\n readonly stderr: ProcessOutputReader;\n readonly done: Promise<ProcessOutcome>;\n kill(): void;\n}',
},
{
name: 'ProcessOutcome',
declaration: 'export interface ProcessOutcome {\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n stdout: CollectedOutput;\n stderr: CollectedOutput;\n}',
},
{
name: 'ProcessOutputRead',
declaration: 'export interface ProcessOutputRead {\n text: string;\n nextOffset: number;\n lossy: boolean;\n spillPath?: string;\n}',
},
{
name: 'ProcessOutputReader',
declaration: 'export interface ProcessOutputReader {\n readFrom(fromByte: number): ProcessOutputRead;\n}',
},
{
name: 'ProcessSpawnSpec',
declaration: 'export interface ProcessSpawnSpec {\n argv: readonly string[];\n cwd: string;\n stdoutMaxBytes: number;\n stderrMaxBytes: number;\n maxSpillBytes: number;\n graceMs: number;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record<string, string> | undefined;\n dshEnv?: DshEnvironment | undefined;\n}',
},
{
name: 'PromptAssembly',
declaration: 'export interface PromptAssembly {\n sections: AssembledSection[];\n tools: ToolSchema[];\n variables: Record<string, string | undefined>;\n}',

View File

@@ -94,8 +94,8 @@ async function makeConsumer(): Promise<string> {
await writeFile(join(dir, 'cordis.yml'), [
'- id: mock-llm',
' name: \'./mock-llm.mjs\'',
'- id: processes',
' name: \'@deepseek-ai/dsh-process-local\'',
'- id: subprocess',
' name: \'@deepseek-ai/dsh-subprocess-local\'',
'- id: bash',
' name: \'@deepseek-ai/dsh-bash-local\'',
'- id: acp-agent',

View File

@@ -35,8 +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: subprocess
name: '@deepseek-ai/dsh-subprocess-local'
- id: bash
name: '@deepseek-ai/dsh-bash-local'
- id: acp-agent

View File

@@ -65,7 +65,7 @@
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-process-local": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",

View File

@@ -5,7 +5,7 @@ import { basename, join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
import LocalProcessManager from '@deepseek-ai/dsh-process-local'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
import SandboxedFileSystem from '@deepseek-ai/dsh-fs-sandbox'
import { CallId } from '@deepseek-ai/dsh-llm'
@@ -53,7 +53,7 @@ beforeEach(async () => {
ctx = new Context()
await ctx.plugin(LocalSandboxProvider, {})
await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: fallbackRoot })
await ctx.plugin(LocalProcessManager)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(SandboxBashExecutor, { cwd: fallbackRoot, timeoutMs: 30_000 })
await ctx.plugin(SandboxedFileSystem, { cwd: fallbackRoot })
await ctx.plugin(agentSpine, {

View File

@@ -80,8 +80,8 @@ async function makeConsumer(): Promise<string> {
await writeFile(join(dir, 'cordis.yml'), [
'- id: mock-llm',
" name: './mock-llm.ts'",
'- id: processes',
" name: '@deepseek-ai/dsh-process-local'",
'- id: subprocess',
" name: '@deepseek-ai/dsh-subprocess-local'",
'- id: bash',
" name: '@deepseek-ai/dsh-bash-local'",
'- id: cli-agent',

View File

@@ -44,7 +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-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-retention": "workspace:^",

View File

@@ -18,7 +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 LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search'
const testToolSignal = new AbortController().signal
@@ -62,7 +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(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, { cwd: dir, timeoutMs: 20_000 })
await ctx.plugin(ToolFsSearch)
})

View File

@@ -46,7 +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-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-hook-protocol": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",

View File

@@ -10,7 +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 LocalSubprocessService from '@deepseek-ai/dsh-subprocess-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'
@@ -53,7 +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(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
const hooks = await ctx.plugin(HooksClaude, { configPath: join(configDir, 'hooks.json') })
ctx.llm.registerAdapter(['mock'], adapter)
@@ -355,7 +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(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
await ctx.plugin(HooksClaude, { configPath: '/nonexistent/hooks.json' })
ctx.llm.registerAdapter(['mock'], adapter)
@@ -377,7 +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(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
const fiber = await ctx.plugin(HooksClaude, { configPath: join(dir, 'hooks.json') })
await fiber.dispose()

View File

@@ -10,7 +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 LocalSubprocessService from '@deepseek-ai/dsh-subprocess-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'
@@ -42,7 +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(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
await ctx.plugin(HooksClaude, { configPath, ...opts })
ctx.llm.registerAdapter(['mock'], adapter)
@@ -361,7 +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(LocalSubprocessService)
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
@@ -660,7 +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(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir })
await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') })
ctx.llm.registerAdapter(['mock'], adapter)
@@ -690,7 +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(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir })
await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') })
ctx.llm.registerAdapter(['mock'], new MockAdapter([]))

View File

@@ -45,7 +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-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-hook-protocol": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",

View File

@@ -10,7 +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 LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
@@ -42,7 +42,7 @@ async function harness(dir: string, adapter: MockAdapter): Promise<Context> {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LocalProcessManager)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'test-model' })
ctx.llm.registerAdapter(['mock'], adapter)
@@ -166,7 +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(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
const fiber = await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'm' })
await fiber.dispose()
@@ -189,7 +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(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
const fiber = await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'm' })
ctx.llm.registerAdapter(['mock'], new MockAdapter([]))

View File

@@ -10,7 +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 LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
@@ -32,7 +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(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
await ctx.plugin(HooksCodex, { configPath, model: 'm', ...opts })
ctx.llm.registerAdapter(['mock'], adapter)
@@ -312,7 +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(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
ctx.logger.warn = warn as never
// Direct apply (schema bypass) → the `model ?? ''` fallback is exercised.
@@ -622,7 +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(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir })
await ctx.plugin(HooksCodex, { configPath: join(serverDir, 'hooks.json'), model: 'm' })
ctx.llm.registerAdapter(['mock'], adapter)

View File

@@ -1,10 +0,0 @@
# process/ — child-process manager capability family
The shared home for spawning managed child-process groups: fully-specified spawn specs, bounded tail-keep output with spill files, credential-scrubbed environments, offset-based incremental reads, and SIGTERM→grace→SIGKILL group kills. Command defaulting, shell semantics, deadlines, and presentation stay with consumers — the [bash executor family](../bash/README.md) is the first and owning consumer. See the [process-manager seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.md).
| Package | ctx key | Role |
|---|---|---|
| [`process`](process/README.md) (`@deepseek-ai/dsh-process`) | `ctx.processes` | The seam: abstract `ProcessManager.spawn(spec)`, the fully-explicit `ProcessSpawnSpec`, `ProcessHandle` with offset-based readers, and the shared `DSH_*` managed-environment and `CollectedOutput` vocabulary |
| [`process-local`](process-local/README.md) (`@deepseek-ai/dsh-process-local`) | — | The local implementation: detached process groups, tail-keep truncation with bounded private spill files, the credential scrub and `DSH_*` merge order, kill escalation, and kill-and-join disposal |
The manager owns process lifetime across consumer reloads; consumers own what a process means (a bash command, a future non-shell runner) and every default that shapes one.

View File

@@ -33,7 +33,7 @@ export function createBuiltinRegistry(profile: ProjectProfile): FeatureRegistry
mode: 'exclusive',
required: true,
baseResources: [
{ kind: 'npm-cordis-config-entry', id: 'processes', package: '@deepseek-ai/dsh-process-local' },
{ kind: 'npm-cordis-config-entry', id: 'subprocess', package: '@deepseek-ai/dsh-subprocess-local' },
{ kind: 'npm-cordis-config-entry', id: 'tool-bash', package: '@deepseek-ai/dsh-tool-bash' },
],
options: [

View File

@@ -10,7 +10,7 @@ import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-inprocess'
/** Cordis companion plugin name. */
export const name = 'subagent-inprocess-invariant'
export const name = 'subagent-insubprocess-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']

View File

@@ -41,7 +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-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",

View File

@@ -3,7 +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 LocalSubprocessService from '@deepseek-ai/dsh-subprocess-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'
@@ -28,7 +28,7 @@ export async function spawnHarness(workdir: string): Promise<Context> {
})
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LlmDeepSeek)
await ctx.plugin(LocalProcessManager)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 })
await ctx.plugin(ToolBash)
await ctx.plugin(SubagentService)

View File

@@ -10,7 +10,7 @@ import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-subprocess'
/** Cordis companion plugin name. */
export const name = 'subagent-subprocess-invariant'
export const name = 'subagent-subsubprocess-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']

View File

@@ -0,0 +1,10 @@
# subprocess/ — subprocess capability family
The shared home for spawning managed child-process groups: fully-specified spawn specs, bounded tail-keep output with spill files, credential-scrubbed environments, offset-based incremental reads, and SIGTERM→grace→SIGKILL group kills. Command defaulting, shell semantics, deadlines, and presentation stay with consumers — the [bash executor family](../bash/README.md) is the first and owning consumer. See the [subprocess seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md).
| Package | ctx key | Role |
|---|---|---|
| [`subprocess`](subprocess/README.md) (`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | The seam: abstract `SubprocessService.spawn(spec)`, the fully-explicit `SubprocessSpawnSpec`, `SubprocessHandle` with offset-based readers, and the shared `DSH_*` managed-environment and `CollectedOutput` vocabulary |
| [`subprocess-local`](subprocess-local/README.md) (`@deepseek-ai/dsh-subprocess-local`) | — | The local implementation: detached process groups, tail-keep truncation with bounded private spill files, the credential scrub and `DSH_*` merge order, kill escalation, and kill-and-join disposal |
The service owns process lifetime across consumer reloads; consumers own what a process means (a bash command, a future non-shell runner) and every default that shapes one.

View File

@@ -1,14 +1,14 @@
# @deepseek-ai/dsh-process-local
# @deepseek-ai/dsh-subprocess-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).
Local implementation of the [`@deepseek-ai/dsh-subprocess`](../subprocess/README.md) seam: `LocalSubprocessService` spawns each spec's argv as a detached process group, collects bounded output with size-limited full-stream spill files, and escalates kills SIGTERM→SIGKILL across the whole group. It has no config: every limit and directory arrives on the spawn spec, so the deployment-varying knobs stay with the calling seam's config ([`dsh-bash-local`](../../bash/bash-local/README.md) today).
## 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.
- **Offset-based reads**`SubprocessHandle` readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist.
- **Kill-and-join disposal** — the service retains live handles only so its own disposal can kill every running group and await its exit; settled and spawn-failed handles leave the live set on settlement.
## Model Experience

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-process-local",
"description": "Local-subprocess implementation of the DeepSeek Harness process-manager seam",
"name": "@deepseek-ai/dsh-subprocess-local",
"description": "Local-subprocess implementation of the DeepSeek Harness subprocess seam",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -28,12 +28,12 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-process": "^0.0.1",
"@deepseek-ai/dsh-subprocess": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-process": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -1,26 +1,26 @@
/**
* Local-subprocess implementation of the process-manager seam. Each spawn is
* Local-subprocess implementation of the subprocess 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
* @module @deepseek-ai/dsh-subprocess-local
*/
import { Context } from 'cordis'
import { ProcessManager } from '@deepseek-ai/dsh-process'
import type { ProcessHandle, ProcessSpawnSpec } from '@deepseek-ai/dsh-process'
import { SubprocessService } from '@deepseek-ai/dsh-subprocess'
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import { spawnProcess } from './spawn.ts'
import type { SpawnInternals } from './spawn.ts'
/**
* Local process manager: detached process groups, tail-keep truncation with
* Local subprocess service: detached process groups, tail-keep truncation with
* bounded spill files, credential-scrubbed environment, and group
* SIGTERMgraceSIGKILL escalation.
*/
export class LocalProcessManager extends ProcessManager {
export class LocalSubprocessService extends SubprocessService {
/** Live handles retained only so disposal can kill and join them. */
private live = new Set<ProcessHandle>()
private live = new Set<SubprocessHandle>()
/** Test seam: spill knobs forwarded to spawnProcess. */
internals: SpawnInternals = {}
@@ -36,10 +36,10 @@ export class LocalProcessManager extends ProcessManager {
}
this.live.clear()
await Promise.all(pending)
}, 'local process-manager teardown')
}, 'local subprocess teardown')
}
spawn(spec: ProcessSpawnSpec): ProcessHandle {
spawn(spec: SubprocessSpawnSpec): SubprocessHandle {
const handle = spawnProcess(spec, this.internals)
this.live.add(handle)
handle.done.then(
@@ -50,4 +50,4 @@ export class LocalProcessManager extends ProcessManager {
}
}
export default LocalProcessManager
export default LocalSubprocessService

View File

@@ -1,16 +1,16 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-process-local`.
* @module @deepseek-ai/dsh-process-local/invariant
* Package-owned invariant companion for `@deepseek-ai/dsh-subprocess-local`.
* @module @deepseek-ai/dsh-subprocess-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'
const PACKAGE_NAME = '@deepseek-ai/dsh-subprocess-local'
/** Cordis companion plugin name. */
export const name = 'process-local-invariant'
export const name = 'subprocess-local-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']

View File

@@ -1,9 +1,9 @@
/**
* Process plumbing for the local process manager: detached process-group
* Process plumbing for the local subprocess service: detached process-group
* spawn, tail-keep output with spill files, and SIGTERMSIGKILL escalation.
* This layer reacts to an abort signal; callers own deadlines and classify
* causes.
* @module dsh-process-local/spawn
* @module dsh-subprocess-local/spawn
*/
import { type ChildProcessByStdio, spawn } from 'node:child_process'
@@ -12,8 +12,8 @@ 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-process'
import type { CollectedOutput, DshEnvironment, ProcessHandle, ProcessOutcome, ProcessSpawnSpec } from '@deepseek-ai/dsh-process'
import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-subprocess'
import type { CollectedOutput, DshEnvironment, SubprocessHandle, SubprocessOutcome, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
/**
* Credential-shaped env vars are NOT forwarded to children (the harness's
@@ -68,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-proc-'))
defaultSpillDir ??= mkdtempSync(join(tmpdir(), 'dsh-subprocess-'))
return defaultSpillDir
}
@@ -140,7 +140,7 @@ export class OutputCollector {
// prediction and symlink planting in shared tmp dirs.
this.spillFile = join(
this.spillDir,
`dsh-proc-${process.pid}-${++spillCounter}-${randomBytes(6).toString('hex')}-${this.label}.log`,
`dsh-subprocess-${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)
@@ -236,12 +236,12 @@ export function killGroup(pid: number, sig: NodeJS.Signals): void {
/**
* Spawn one isolated detached process group and collect its output.
* Runtime exits resolve as {@link ProcessOutcome}; only spawn failures reject.
* Runtime exits resolve as {@link SubprocessOutcome}; 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.
*/
export function spawnProcess(spec: ProcessSpawnSpec, internals: SpawnInternals = {}): ProcessHandle {
export function spawnProcess(spec: SubprocessSpawnSpec, internals: SpawnInternals = {}): SubprocessHandle {
const spillDir = internals.spillDir ?? privateSpillDir()
if (spec.signal?.aborted) {
@@ -264,12 +264,17 @@ export function spawnProcess(spec: ProcessSpawnSpec, internals: SpawnInternals =
child.stderr.on('data', (chunk: Buffer) => { stderr.push(chunk) })
let graceTimer: NodeJS.Timeout | undefined
let settled = false
// Failed spawns use pid -1 so kill remains a no-op.
const pid = child.pid ?? -1
const kill = (): void => {
if (graceTimer !== undefined) return // escalation already in flight
// After settlement the group is gone and the pid may be reused; callers
// commonly kill() in a finally, so this must not re-signal or start a
// timer that outlives the handle.
if (settled) return
killGroup(pid, 'SIGTERM')
graceTimer = setTimeout(() => { killGroup(pid, 'SIGKILL') }, spec.graceMs)
}
@@ -284,8 +289,7 @@ export function spawnProcess(spec: ProcessSpawnSpec, internals: SpawnInternals =
child.stdin.end(spec.stdin)
}
const done = new Promise<ProcessOutcome>((resolve, reject) => {
let settled = false
const done = new Promise<SubprocessOutcome>((resolve, reject) => {
let pipeDrainTimer: NodeJS.Timeout | undefined
const settle = (exitCode: number | null, signal: NodeJS.Signals | null): void => {
if (settled) return

View File

@@ -1,9 +1,9 @@
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'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
function spec(command: string, overrides: Partial<ProcessSpawnSpec> = {}): ProcessSpawnSpec {
function spec(command: string, overrides: Partial<SubprocessSpawnSpec> = {}): SubprocessSpawnSpec {
return {
argv: ['bash', '-c', command],
cwd: process.cwd(),
@@ -15,11 +15,11 @@ function spec(command: string, overrides: Partial<ProcessSpawnSpec> = {}): Proce
}
}
describe('LocalProcessManager', () => {
it('registers as ctx.processes and spawns managed handles', async () => {
describe('LocalSubprocessService', () => {
it('registers as ctx.subprocess 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
const fiber = await ctx.plugin(LocalSubprocessService)
const result = await ctx.subprocess.spawn(spec('echo managed')).done
expect(result.exitCode).toBe(0)
expect(result.stdout.text).toBe('managed\n')
await fiber.dispose()
@@ -27,8 +27,8 @@ describe('LocalProcessManager', () => {
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'))
const fiber = await ctx.plugin(LocalSubprocessService)
const handle = ctx.subprocess.spawn(spec('sleep 60'))
await fiber.dispose()
const outcome = await handle.done
expect(outcome.signal).toBe('SIGTERM')
@@ -36,8 +36,8 @@ describe('LocalProcessManager', () => {
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 fiber = await ctx.plugin(LocalSubprocessService)
const handle = ctx.subprocess.spawn(spec('true'))
const outcome = await handle.done
expect(outcome.exitCode).toBe(0)
await fiber.dispose()
@@ -45,26 +45,26 @@ describe('LocalProcessManager', () => {
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' }))
const fiber = await ctx.plugin(LocalSubprocessService)
const handle = ctx.subprocess.spawn(spec('true', { cwd: '/nonexistent-dir-dsh-subprocess-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)
const fiber = await ctx.plugin(LocalSubprocessService)
// 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' }))
const handle = ctx.subprocess.spawn(spec('true', { cwd: '/nonexistent-dir-dsh-subprocess-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/)
await ctx.plugin(LocalSubprocessService)
class SecondManager extends LocalSubprocessService {}
await expect(ctx.plugin(SecondManager)).rejects.toThrow(/service "subprocess" has been registered/)
})
})

View File

@@ -2,9 +2,9 @@ import { mkdtempSync, readFileSync, statSync, unlinkSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import type { DshEnvironment } from '@deepseek-ai/dsh-process'
import type { DshEnvironment } from '@deepseek-ai/dsh-subprocess'
import { killGroup, OutputCollector, spawnProcess } from '../src/spawn.ts'
import type { ProcessHandle } from '@deepseek-ai/dsh-process'
import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess'
const { failNextClose, failNextUnlink } = vi.hoisted(() => ({
failNextClose: { value: false },
@@ -31,7 +31,7 @@ vi.mock('node:fs', async (importOriginal) => {
}
})
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-proc-spec-'))
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-subprocess-spec-'))
function spec(command: string, overrides: Partial<Parameters<typeof spawnProcess>[0]> = {}) {
return {
@@ -59,7 +59,7 @@ async function waitGone(pid: number, timeoutMs = 5_000): Promise<void> {
throw new Error(`pid ${pid} still alive after ${timeoutMs}ms`)
}
async function waitForStdout(running: ProcessHandle, expected: string, timeoutMs = 5_000): Promise<void> {
async function waitForStdout(running: SubprocessHandle, expected: string, timeoutMs = 5_000): Promise<void> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (running.stdout.readFrom(0).text.includes(expected)) return
@@ -407,6 +407,21 @@ describe('killGroup', () => {
await running.done
expect(() => { killGroup(running.pid, 'SIGTERM') }).not.toThrow()
})
it('handle.kill() after settlement signals nothing and starts no grace timer', async () => {
// Cleanup code commonly kills handles in a finally; after settlement the
// group is gone and the pid may be reused, so a late kill must be inert
// (no signal to a possibly-recycled pgid, no referenced timer delaying exit).
const running = spawnProcess(spec('true'))
await running.done
const spy = vi.spyOn(process, 'kill')
try {
running.kill()
expect(spy).not.toHaveBeenCalled()
} finally {
spy.mockRestore()
}
})
})
describe('argv validation', () => {
@@ -490,7 +505,7 @@ describe('environment and spill-file hardening', () => {
{ spillDir },
).done
const path = result.stdout.spillPath!
expect(path).toMatch(/dsh-proc-\d+-\d+-[0-9a-f]{12}-stdout\.log$/)
expect(path).toMatch(/dsh-subprocess-\d+-\d+-[0-9a-f]{12}-stdout\.log$/)
const mode = statSync(path).mode & 0o777
expect(mode).toBe(0o600)
})
@@ -500,7 +515,7 @@ describe('environment and spill-file hardening', () => {
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-proc-/)
expect(dir).toMatch(/dsh-subprocess-/)
const mode = statSync(dir).mode & 0o777
expect(mode).toBe(0o700)
})

View File

@@ -15,7 +15,7 @@
"path": "../../../vendor/cordis"
},
{
"path": "../process"
"path": "../subprocess"
},
{
"path": "../../support/invariants"

View File

@@ -1,16 +1,16 @@
# @deepseek-ai/dsh-process
# @deepseek-ai/dsh-subprocess
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).
The subprocess seam (`ctx.subprocess`). The abstract `SubprocessService` exposes one method — `spawn(spec): SubprocessHandle` — plus the vocabulary shared by every consumer: the fully-explicit `SubprocessSpawnSpec`, `SubprocessHandle` with its non-consuming offset-based output readers, `SubprocessOutcome`, `CollectedOutput`, and the managed `DSH_*` environment namespace (`DSH_ENV_PREFIX`, `DshEnvironment`). The local implementation lives in [`dsh-subprocess-local`](../subprocess-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.
- The spec is fully explicit — argv, cwd, per-stream byte caps, spill cap, grace — because deployment-varying defaults belong to the calling seam's config, not to a hidden subprocess-service default (the `dsh-bash` request/spec split is the owning template). `argv` is never shell-interpreted here; a consumer that wants a shell passes `['bash', '-c', command]` itself.
- Output readers take whole-stream byte offsets and never consume: independent readers cannot steal one another's deltas. A read whose offset slid out of the in-memory tail is `lossy` and points at the full-stream spill file when one exists.
- `kill()` and the spec's abort signal escalate SIGTERM→grace→SIGKILL across the whole detached group; the manager reacts to the abort but never classifies why (callers own deadlines and cause classification).
- `kill()` and the spec's abort signal escalate SIGTERM→grace→SIGKILL across the whole detached group; the service reacts to the abort but never classifies why (callers own deadlines and cause classification).
- Disposal kills all still-running managed processes and awaits their exit.
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).
See the [process data-structure catalog](../../../docs/core-data-structures/subprocess.md) and the [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md).
## Model Experience

View File

@@ -1,6 +1,6 @@
{
"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",
"name": "@deepseek-ai/dsh-subprocess",
"description": "Subprocess seam (ctx.subprocess) 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",

View File

@@ -1,37 +1,37 @@
/**
* The child-process manager seam (`ctx.processes`): spawn fully-specified
* The subprocess seam (`ctx.subprocess`): 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
* `@deepseek-ai/dsh-subprocess-local`.
* @module @deepseek-ai/dsh-subprocess
*/
import { Context, Service } from 'cordis'
import type { ProcessHandle, ProcessSpawnSpec } from './types.ts'
import type { SubprocessHandle, SubprocessSpawnSpec } from './types.ts'
export { DSH_ENV_PREFIX } from './types.ts'
export type {
CollectedOutput,
DshEnvironment,
DshEnvironmentKey,
ProcessHandle,
ProcessOutcome,
ProcessOutputRead,
ProcessOutputReader,
ProcessSpawnSpec,
SubprocessHandle,
SubprocessOutcome,
SubprocessOutputRead,
SubprocessOutputReader,
SubprocessSpawnSpec,
} from './types.ts'
declare module 'cordis' {
interface Context {
processes: ProcessManager
subprocess: SubprocessService
}
}
/**
* Abstract process manager. Subclass, implement {@link spawn}, and load the
* subclass as a plugin it registers as `ctx.processes` (one implementation
* Abstract subprocess service. Subclass, implement {@link spawn}, and load the
* subclass as a plugin it registers as `ctx.subprocess` (one implementation
* per context; loading a second throws, which is cordis' standard
* duplicate-service behavior).
*
@@ -41,13 +41,13 @@ declare module 'cordis' {
* - 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
* - {@link SubprocessHandle.kill} and the spec's abort signal escalate
* SIGTERMgraceSIGKILL across the whole process group.
* - Disposal kills all still-running managed processes and awaits their exit.
*/
export abstract class ProcessManager extends Service {
export abstract class SubprocessService extends Service {
constructor(ctx: Context) {
super(ctx, 'processes')
super(ctx, 'subprocess')
}
/**
@@ -56,7 +56,7 @@ export abstract class ProcessManager extends Service {
* @param spec - argv, directory, limits, grace, cancellation, and environment.
* @returns the live process handle (readers, kill, outcome promise).
*/
abstract spawn(spec: ProcessSpawnSpec): ProcessHandle
abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle
}
export default ProcessManager
export default SubprocessService

View File

@@ -1,12 +1,12 @@
/** Package-owned invariant companion for the process-manager seam. @module @deepseek-ai/dsh-process/invariant */
/** Package-owned invariant companion for the subprocess seam. @module @deepseek-ai/dsh-subprocess/invariant */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-process'
const PACKAGE_NAME = '@deepseek-ai/dsh-subprocess'
/** Cordis companion plugin name. */
export const name = 'process-invariant'
export const name = 'subprocess-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
@@ -14,7 +14,7 @@ export const inject = ['invariants']
const install: InvariantInstaller = () => {}
/**
* Register the process-manager invariant companion.
* Register the subprocess invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/

View File

@@ -1,9 +1,9 @@
/**
* Vocabulary for the process-manager seam: fully-specified spawn requests,
* Vocabulary for the subprocess 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
* @module dsh-subprocess/types
*/
/** Namespace prefix reserved for DeepSeek Harness-managed child environment facts. */
@@ -28,10 +28,10 @@ export interface CollectedOutput {
/**
* 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
* subprocess-service default decides them (the `dsh-bash` request/spec split
* is the owning template).
*/
export interface ProcessSpawnSpec {
export interface SubprocessSpawnSpec {
/** Executable and arguments; `argv[0]` is the program. Never shell-interpreted here. */
argv: readonly string[]
/** Working directory for the child. */
@@ -70,10 +70,10 @@ export interface ProcessSpawnSpec {
/**
* Raw outcome of one closed process. Deliberately carries NO timeout or
* cancellation classification: the manager kills on abort but does not decide
* cancellation classification: the service kills on abort but does not decide
* why the caller reads the signal it owns to classify causes.
*/
export interface ProcessOutcome {
export interface SubprocessOutcome {
/** Exit code; null when the process died from a signal. */
exitCode: number | null
/** Terminating signal (e.g. 'SIGTERM'); null on normal exit. */
@@ -82,8 +82,8 @@ export interface ProcessOutcome {
stderr: CollectedOutput
}
/** One incremental {@link ProcessOutputReader.readFrom} read. */
export interface ProcessOutputRead {
/** One incremental {@link SubprocessOutputReader.readFrom} read. */
export interface SubprocessOutputRead {
/** Stream text from the requested offset (the whole retained tail when lossy). */
text: string
/** Whole-stream offset to resume from on the next read. */
@@ -99,7 +99,7 @@ export interface ProcessOutputRead {
* whole-stream byte coordinates owned by the caller, so independent readers
* cannot consume one another's output.
*/
export interface ProcessOutputReader {
export interface SubprocessOutputReader {
/**
* 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
@@ -107,22 +107,22 @@ export interface ProcessOutputReader {
* @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
readFrom(fromByte: number): SubprocessOutputRead
}
/**
* A live child process. `kill()` starts the group SIGTERMgraceSIGKILL
* escalation; buffered output remains readable after exit.
*/
export interface ProcessHandle {
export interface SubprocessHandle {
/** Process id (group leader); -1 when the spawn itself failed. */
readonly pid: number
/** Live stdout reader (also readable after exit). */
readonly stdout: ProcessOutputReader
readonly stdout: SubprocessOutputReader
/** Live stderr reader (also readable after exit). */
readonly stderr: ProcessOutputReader
readonly stderr: SubprocessOutputReader
/** Resolves when the process closes; rejects only for spawn-level failures. */
readonly done: Promise<ProcessOutcome>
readonly done: Promise<SubprocessOutcome>
/** Begin SIGTERM→grace→SIGKILL on the process group. Idempotent. */
kill(): void
}

View File

@@ -1,16 +1,16 @@
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'
import { SubprocessService } from '@deepseek-ai/dsh-subprocess'
import type { SubprocessHandle, SubprocessOutputRead, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
/**
* Minimal concrete manager: a hand-built handle. The seam is spawn-only
* Minimal concrete service: 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 }
class StubSubprocessService extends SubprocessService {
spawn(spec: SubprocessSpawnSpec): SubprocessHandle {
const read: SubprocessOutputRead = { text: '', nextOffset: 0, lossy: false }
let killed = false
return {
pid: spec.argv.length,
@@ -27,11 +27,11 @@ class StubProcessManager extends ProcessManager {
}
}
describe('ProcessManager seam', () => {
it('a concrete subclass registers as ctx.processes and serves the abstract API', async () => {
describe('SubprocessService seam', () => {
it('a concrete subclass registers as ctx.subprocess and serves the abstract API', async () => {
const ctx = new Context()
await ctx.plugin(StubProcessManager)
const handle = ctx.processes.spawn({
await ctx.plugin(StubSubprocessService)
const handle = ctx.subprocess.spawn({
argv: ['true'],
cwd: '/stub',
stdoutMaxBytes: 1,
@@ -48,8 +48,8 @@ describe('ProcessManager seam', () => {
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/)
await ctx.plugin(StubSubprocessService)
class SecondManager extends StubSubprocessService {}
await expect(ctx.plugin(SecondManager)).rejects.toThrow(/service "subprocess" has been registered/)
})
})