diff --git a/docs/architecture.md b/docs/architecture.md
index 02dac4a2dd..eaeb86d913 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -31,6 +31,7 @@ The default distribution is a composition, not a hierarchy. `packages/core/` is
| `ctx.web` | [`web/`](../packages/web/README.md) | search/fetch provider registries |
| `ctx.compact` | [`compact/`](../packages/compact/README.md) | session-surface compaction |
| `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | named delegation providers |
+| `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | background task registry + generic `task_*` control tools |
| `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | durable storage for session logs |
## Event Surface
@@ -101,7 +102,7 @@ Every session event is turn-enclosed. Reloading a crashed session preserves the
### Agent Handles
-`ctx.agents` owns live agents and returns an `AgentHandle { agent, dispose() }`. `Agent` is the surface other plugins drive: `send()` queues work, `steer()` injects mid-turn content, `inject()` appends context and opens a one-shot injection turn when idle, `cancel()` is the public stop primitive, and `whenIdle()` observes quiescence. Lifecycle owners tear down with `await dispose()`.
+`ctx.agents` owns live agents and returns an `AgentHandle { agent, dispose() }`. `Agent` is the surface other plugins drive: `send()` queues work, `steer()` injects mid-turn content, `inject()` appends context and opens a one-shot injection turn when idle, `cancel()` is the public stop primitive, and `whenIdle()` observes quiescence. Lifecycle owners tear down with `await dispose()`, whose chain also awaits every `ctx.agents.onCleanup` registration — the seam tying resources (background tasks) to the owner's quiescence.
## State And Model Surface
@@ -140,6 +141,7 @@ New behavior should attach to a documented seam; changing the shipped loop requi
| Add a model provider | register an adapter on `ctx.llm` |
| Add a model-facing capability | register a tool on `ctx.tools`; schemas flow into prompt assembly |
| Add command execution | implement and register a `ctx.bash` backend |
+| Add a long-running/background capability | register the work on `ctx.tasks`; the generic `task_*` tools collect/stop it |
| Add filesystem access or policy | implement a `ctx.fs` provider or listen on `fs/*` policy events |
| Intercept prompts, requests, tool use, or continuation | listen on the relevant `agent/*` or `tools/*` waterfall |
| Add UI or editor integration | drive `ctx.agents` and render from `session/event` |
diff --git a/docs/capability-seams.md b/docs/capability-seams.md
index 339954c00f..54a271423e 100644
--- a/docs/capability-seams.md
+++ b/docs/capability-seams.md
@@ -56,6 +56,9 @@ flowchart LR
pkg_subagent_fork["subagent-fork"]
pkg_subagent_acp["subagent-acp"]
pkg_subagent_mock["subagent-mock"]
+ pkg_tasks["tasks"]
+ svc_tasks["ctx.tasks Background task registry"]
+ pkg_tool_tasks["tool-tasks"]
pkg_web["web"]
svc_web["ctx.web Web access provider registry"]
pkg_web_search_exa["web-search-exa"]
@@ -85,6 +88,7 @@ flowchart LR
pkg_subagent_mock --> svc_subagents
pkg_subagent_spawn --> svc_subagents
pkg_system_prompt --> svc_systemPrompt
+ pkg_tasks --> svc_tasks
pkg_tools --> svc_tools
pkg_web --> svc_web
pkg_web_fetch_local --> svc_web
@@ -116,6 +120,9 @@ flowchart LR
svc_systemPrompt --> pkg_tool_fs
svc_systemPrompt --> pkg_tool_web
svc_systemPrompt --> pkg_tools
+ svc_tasks --> pkg_tool_bash
+ svc_tasks --> pkg_tool_subagent
+ svc_tasks --> pkg_tool_tasks
svc_tools --> pkg_acp
svc_tools --> pkg_agent_loop
svc_tools --> pkg_tool_bash
@@ -141,6 +148,7 @@ flowchart LR
| `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-policy`](../packages/fs/fs-policy) | tool-fs executes read/write/edit through ctx.fs; fs-policy contributes observed-state checks through the fs/* event gate. |
| `ctx.compact` | `seam` | [`compact`](../packages/compact/compact) | [`compact-basic`](../packages/compact/compact-basic) | [`compact-basic`](../packages/compact/compact-basic) | - | The basic backend currently consumes the pre-step event directly; a model-facing compact tool remains deferred. |
| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-mock`](../packages/support/subagent-mock) | [`tool-subagent`](../packages/subagent/tool-subagent) | - | Providers implement transports; tool-subagent exposes one configured provider as a model-facing tool name. |
+| `ctx.tasks` | `core` | [`tasks`](../packages/tasks/tasks) | - | [`tool-bash`](../packages/bash/tool-bash), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-tasks`](../packages/tasks/tool-tasks) | - | Producers (tool-bash background commands, tool-subagent background delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it. |
| `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-local`](../packages/web/web-fetch-local) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. |
Maintenance mode: hybrid: services are discovered from Cordis declarations; interface/implementation/consumer roles are classified in `scripts/gen-doc-graphs.ts` with a completeness guard.
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index e55066101f..f59260810f 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -83,7 +83,7 @@ export interface Config {
Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt)
-Source: [`packages/core/agent-core/src/index.ts:69`](../packages/core/agent-core/src/index.ts)
+Source: [`packages/core/agent-core/src/index.ts:72`](../packages/core/agent-core/src/index.ts)
## `@deepseek-ai/dsh-agent-loop`
@@ -139,7 +139,7 @@ export interface Config {
}
```
-Source: [`packages/bash/bash-local/src/index.ts:28`](../packages/bash/bash-local/src/index.ts)
+Source: [`packages/bash/bash-local/src/index.ts:29`](../packages/bash/bash-local/src/index.ts)
## `@deepseek-ai/dsh-compact-basic`
@@ -602,6 +602,25 @@ export interface Config {
Source: [`packages/core/system-prompt/src/index.ts:179`](../packages/core/system-prompt/src/index.ts)
+## `@deepseek-ai/dsh-tool-bash`
+
+Requires: `tools` · `bash` · `systemPrompt`
+
+```ts config-catalog
+/** Config: whether the model may background commands (the producer-opt-in flag). */
+export interface Config {
+ /**
+ * Expose `run_in_background` in the bash schema (default true). Disabled,
+ * the parameter is absent entirely — schema and capability never disagree.
+ * Backgrounding also needs the `ctx.tasks` runtime at call time; a missing
+ * one fails the call loud with the load-these-packages message.
+ */
+ enableRunInBackground?: boolean
+}
+```
+
+Source: [`packages/bash/tool-bash/src/index.ts:43`](../packages/bash/tool-bash/src/index.ts)
+
## `@deepseek-ai/dsh-tool-fs`
Requires: `tools` · `fs` · `systemPrompt`
@@ -639,6 +658,14 @@ export interface Config {
* `{ provider: 'acp', toolName: 'subagent_acp' }`.
*/
toolName?: string
+ /**
+ * Expose `run_in_background` in this instance's schema (default true).
+ * Disabled, the parameter is absent entirely — schema and capability never
+ * disagree; delegation through this instance stays strictly synchronous.
+ * Backgrounding also needs the `ctx.tasks` runtime at call time; a missing
+ * one fails the call loud with the load-these-packages message.
+ */
+ enableRunInBackground?: boolean
/**
* Default per-child agent options (model) applied to every spawned child.
* Omitted fields fall back to the child loop's own defaults. There is no
@@ -651,7 +678,23 @@ export interface Config {
Depends on: [`AgentOptions`](../packages/core/agent/src/index.ts)
-Source: [`packages/subagent/tool-subagent/src/index.ts:44`](../packages/subagent/tool-subagent/src/index.ts)
+Source: [`packages/subagent/tool-subagent/src/index.ts:56`](../packages/subagent/tool-subagent/src/index.ts)
+
+## `@deepseek-ai/dsh-tool-tasks`
+
+Requires: `tools` · `tasks` · `systemPrompt`
+
+```ts config-catalog
+/** Config: the `task_output` wait bounds (defaulted, capped — never hardcoded). */
+export interface Config {
+ /** Wait duration applied when `task_output` sets `wait` without `timeout_ms` (default 30s). */
+ waitTimeoutMs?: number
+ /** Hard cap on any single wait; a larger model-supplied `timeout_ms` is clamped down to it (default 10min). */
+ maxWaitTimeoutMs?: number
+}
+```
+
+Source: [`packages/tasks/tool-tasks/src/index.ts:34`](../packages/tasks/tool-tasks/src/index.ts)
## `@deepseek-ai/dsh-tool-web`
@@ -793,7 +836,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
- `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts))
- `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts))
- `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts))
-- `@deepseek-ai/dsh-tool-bash` — requires `tools` · `bash` · `systemPrompt` ([`packages/bash/tool-bash/src/index.ts`](../packages/bash/tool-bash/src/index.ts))
+- `@deepseek-ai/dsh-tasks` ([`packages/tasks/tasks/src/index.ts`](../packages/tasks/tasks/src/index.ts))
- `@deepseek-ai/dsh-tool-todo` — requires `tools` ([`packages/todo/tool-todo/src/index.ts`](../packages/todo/tool-todo/src/index.ts))
- `@deepseek-ai/dsh-tools` — requires `systemPrompt` ([`packages/core/tools/src/index.ts`](../packages/core/tools/src/index.ts))
diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md
index 9180d88489..ba89caf387 100644
--- a/docs/cookbook/adding-a-tool.md
+++ b/docs/cookbook/adding-a-tool.md
@@ -41,9 +41,7 @@ Registration is effect-based: disposing the plugin fiber unregisters the tool (w
## Long-running work
-Follow tool-bash's background pattern: a `run_in_background` flag returns a task id immediately; companion tools poll incrementally and kill; completion notices arrive via `agent.inject()`. Bound buffers and spill full output to disk so nothing is silently lost.
-
-> TODO: each tool reimplements this background pattern by hand today. At some point we need a generic long-running-tool layer that handles task ids, incremental polling, kill, and completion notices uniformly.
+Register the running work with the shared task runtime instead of inventing a task protocol: gate a `run_in_background` parameter behind your plugin's own defaulted `enableRunInBackground`-style config, start the work, and hand it to `ctx.tasks.register({ kind, label, owner: exec.agent, cancel, done, readOutput? })` (`@deepseek-ai/dsh-tasks`). The runtime issues the `-N` id, fences access to the owning session, cancels-and-awaits your task when the owner disposes, and the generic `task_output`/`task_list`/`task_kill` tools plus the completion notice come from `@deepseek-ai/dsh-tool-tasks` — your tool returns `started background task ` and is done. Your producer keeps its execution concerns: `done` must settle at quiescence (resources released), and a stream-kind `readOutput` owns its own truncation/spill formatting (bound buffers, spill full output to disk so nothing is silently lost — see tool-bash's `renderProcessRead`). Do NOT wire `exec.signal` to the background work after the id is returned; check `exec.signal?.aborted` once before starting, then leave cancellation to `task_kill` and owner cleanup. **A failed `register()` must not orphan the work**: `register()` is atomic (a throw — the no-control-surface fence, a bad owner — mutates no registry state), so wrap it in try/catch, cancel the just-started work, AWAIT its quiescence, and rethrow — the model never learns an id, so nothing else could ever collect or kill what you started (tool-bash's `proc.kill(); await proc.done` and tool-subagent's `run.cancel(); await done` are the templates).
## Permissions / sandboxing
diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md
index 8d776a75ef..ed6e7dad33 100644
--- a/docs/cordis-catalog/events.md
+++ b/docs/cordis-catalog/events.md
@@ -243,7 +243,7 @@ A subagent run settled — emitted when SubagentRun.result resolves (any stop re
'subagent/end'(info: SubagentRunEndInfo): void
```
-Source: [`packages/subagent/subagent/src/index.ts:98`](../../packages/subagent/subagent/src/index.ts)
+Source: [`packages/subagent/subagent/src/index.ts:99`](../../packages/subagent/subagent/src/index.ts)
### `subagent/provider-added` — emit
@@ -253,7 +253,7 @@ A provider became resolvable in the SubagentService registry. Consumers that der
'subagent/provider-added'(provider: SubagentProvider): void
```
-Source: [`packages/subagent/subagent/src/index.ts:72`](../../packages/subagent/subagent/src/index.ts)
+Source: [`packages/subagent/subagent/src/index.ts:73`](../../packages/subagent/subagent/src/index.ts)
### `subagent/provider-removed` — emit
@@ -263,7 +263,7 @@ A provider left the registry (its plugin's fiber was disposed — an unload or a
'subagent/provider-removed'(name: string): void
```
-Source: [`packages/subagent/subagent/src/index.ts:83`](../../packages/subagent/subagent/src/index.ts)
+Source: [`packages/subagent/subagent/src/index.ts:84`](../../packages/subagent/subagent/src/index.ts)
### `subagent/start` — emit
@@ -273,7 +273,7 @@ A subagent run started — emitted after the provider is resolved and its capabi
'subagent/start'(info: SubagentRunInfo): void
```
-Source: [`packages/subagent/subagent/src/index.ts:91`](../../packages/subagent/subagent/src/index.ts)
+Source: [`packages/subagent/subagent/src/index.ts:92`](../../packages/subagent/subagent/src/index.ts)
## `system-prompt/*`
diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md
index 26126c4481..42179d4bd3 100644
--- a/docs/cordis-catalog/services.md
+++ b/docs/cordis-catalog/services.md
@@ -33,12 +33,14 @@ create(options: CreateAgentOptions): AgentHandle
async resume(options: ResumeAgentOptions): Promise
register(agent: Agent): () => void
get(id: AgentId): Agent | undefined
+onCleanup(agentId: AgentId, cleanup: () => Promise): () => void
+async drainCleanups(agentId: AgentId): Promise
list(): Agent[]
```
Types: [Agent](../core-data-structures/core.md)
-Source: [`packages/core/agent/src/index.ts:117`](../../packages/core/agent/src/index.ts)
+Source: [`packages/core/agent/src/index.ts:124`](../../packages/core/agent/src/index.ts)
## `ctx.bash` — `BashExecutor` (abstract seam)
@@ -47,25 +49,19 @@ Abstract bash execution service. Subclass, implement the abstract methods, and l
Semantics every implementation must honor:
- run REJECTS only for infrastructure failures (unusable workdir, missing shell, pre-aborted signal). Nonzero exits, timeout kills, and abort kills RESOLVE with a descriptive BashRunResult — reporting a failed command is the tool layer's job, not an exception.
-- start returns immediately; no timeout applies to background tasks (callers stop them via kill or the spec's AbortSignal). Completion must fire the onTaskDone listeners exactly once per task, and must NOT fire after the service is disposed.
-- readOutput is incremental: consecutive reads never re-deliver output. Implementations bound their buffers; reads that lost data flag `lossy` and point at full-stream spill files when available.
-- Disposal kills every running task and awaits their exit (no orphan processes survive `fiber.dispose()`).
+- start returns immediately; no timeout applies to background processes (callers stop them via BashProcess.kill or the spec's AbortSignal). The handle's `done` settles at process close and never rejects (a spawn failure settles as `killed` with the error readable on stderr).
+- BashProcess.readOutput is incremental: consecutive reads never re-deliver output. Implementations bound their buffers; reads that lost data flag `lossy` and point at full-stream spill files when available.
+- Disposal kills every running background process and awaits their exit (no orphan processes survive `fiber.dispose()`).
```ts cordis-catalog
abstract resolve(request: BashExecRequest): BashExecSpec
abstract run(spec: BashExecSpec): Promise
-abstract start(spec: BashExecSpec): BashTask
-abstract get(id: BashTaskId): BashTask | undefined
-abstract ownerOf(id: BashTaskId): OwnerToken | undefined
-abstract list(): BashTask[]
-abstract readOutput(id: BashTaskId): BashTaskRead
-abstract kill(id: BashTaskId): boolean
-onTaskDone(listener: BashTaskListener): () => void
+abstract start(spec: BashExecSpec): BashProcess
```
-Types: [BashExecRequest](../core-data-structures/bash.md) · [BashExecSpec](../core-data-structures/bash.md) · [BashRunResult](../core-data-structures/bash.md) · [BashTask](../core-data-structures/bash.md) · [BashTaskRead](../core-data-structures/bash.md)
+Types: [BashExecRequest](../core-data-structures/bash.md) · [BashExecSpec](../core-data-structures/bash.md) · [BashRunResult](../core-data-structures/bash.md)
-Source: [`packages/bash/bash/src/index.ts:59`](../../packages/bash/bash/src/index.ts)
+Source: [`packages/bash/bash/src/index.ts:65`](../../packages/bash/bash/src/index.ts)
## `ctx.codeRuntime` — `CodeRuntime` (abstract seam)
@@ -196,7 +192,7 @@ list(): string[]
start(name: string, request: SubagentStartRequest): SubagentRun
```
-Source: [`packages/subagent/subagent/src/index.ts:144`](../../packages/subagent/subagent/src/index.ts)
+Source: [`packages/subagent/subagent/src/index.ts:145`](../../packages/subagent/subagent/src/index.ts)
## `ctx.systemPrompt` — `SystemPrompt`
@@ -211,6 +207,25 @@ async assemble(context: AssembleContext = {}): Promise
Source: [`packages/core/system-prompt/src/index.ts:291`](../../packages/core/system-prompt/src/index.ts)
+## `ctx.tasks` — `TaskService`
+
+The `tasks` service: the runtime-global background task registry. See the module doc for the ownership, isolation, and lifecycle contracts.
+
+```ts cordis-catalog
+register(registration: TaskRegistration): TaskId
+list(caller?: Agent): TaskSnapshot[]
+get(id: TaskId, caller?: Agent): TaskSnapshot
+read(id: TaskId, caller?: Agent): TaskRead
+kill(id: TaskId, caller?: Agent, reason?: string): 'requested' | 'already-terminal'
+async wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise
+onTaskDone(listener: TaskDoneListener): () => void
+attachSurface(name: string): () => void
+```
+
+Types: [Agent](../core-data-structures/core.md)
+
+Source: [`packages/tasks/tasks/src/index.ts:84`](../../packages/tasks/tasks/src/index.ts)
+
## `ctx.tools` — `ToolRegistry`
Tool registry (`ctx.tools`): tool plugins register definitions; the agent loop executes calls through the `tools/pre-execute` → dispatch → `tools/post-execute` pipeline. The registry contributes its schemas into the system-prompt assembly.
diff --git a/docs/core-data-structures/bash.md b/docs/core-data-structures/bash.md
index 273ba5ebe8..ffa83cd22a 100644
--- a/docs/core-data-structures/bash.md
+++ b/docs/core-data-structures/bash.md
@@ -1,6 +1,6 @@
# Bash Executor
-The bash execution seam — the canonical [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md) example, split across three packages: interface ([dsh-bash](../../packages/bash/bash), `ctx.bash`), implementation ([dsh-bash-local](../../packages/bash/bash-local), local subprocesses), and consumer ([dsh-tool-bash](../../packages/bash/tool-bash), the `bash`/`bash_output`/`bash_kill` tool schemas). Bash is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A sandboxed, containerized, or remote backend is a sibling package implementing the same interface.
+The bash execution seam — the canonical [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md) example, split across three packages: interface ([dsh-bash](../../packages/bash/bash), `ctx.bash`), implementation ([dsh-bash-local](../../packages/bash/bash-local), local subprocesses), and consumer ([dsh-tool-bash](../../packages/bash/tool-bash), the `bash` tool schema). Bash is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A sandboxed, containerized, or remote backend is a sibling package implementing the same interface.
Source: [`packages/bash/bash/src/types.ts`](../../packages/bash/bash/src/types.ts)
@@ -35,15 +35,6 @@ interface BashExecRequest {
* uses shell syntax like `FOO=bar cmd`).
*/
env?: Record | undefined
- /**
- * Opaque OWNER token for a background task — the consumer's isolation key
- * (the tool layer passes the owning agent's `session.header.id`). The
- * executor stores it on the task and exposes it via {@link BashExecutor.ownerOf};
- * the executor itself NEVER interprets it (no access policy lives in the
- * seam — that is the consumer's job). Absent for foreground runs and for an
- * ownerless background start (a non-agent caller).
- */
- owner?: OwnerToken | undefined
}
```
@@ -56,10 +47,10 @@ interface BashExecSpec {
signal?: AbortSignal | undefined
/**
* Bytes to write to the command's stdin (then close it), carried through
- * verbatim from {@link BashExecRequest.stdin}. OPTIONAL on the resolved spec
- * (unlike `owner`): it has no config default, so a missing one means "no
- * stdin" — the safe, ordinary case — not a silent footgun, so it stays a
- * plain optional rather than required-but-nullable (see the request field).
+ * verbatim from {@link BashExecRequest.stdin}. OPTIONAL on the resolved spec:
+ * it has no config default, so a missing one means "no stdin" — the safe,
+ * ordinary case — not a silent footgun, so it stays a plain optional rather
+ * than required-but-nullable (see the request field).
*/
stdin?: string | undefined
/**
@@ -70,23 +61,12 @@ interface BashExecSpec {
* config default, absent means "no extra env".
*/
env?: Record | undefined
- /**
- * Opaque owner token, REQUIRED-but-nullable (mirrors `workdir`/`timeoutMs`
- * being required on the resolved spec): {@link BashExecutor.resolve} carries
- * the request's `owner` through, defaulting a missing one to `undefined`. A
- * required field makes a forgotten owner a VISIBLE `undefined` rather than a
- * silently-absent property that yields an unowned (cross-session-readable)
- * task. `start()` stores it; `run()` (foreground) ignores it.
- */
- owner: OwnerToken | undefined
}
```
-The `owner` token is the isolation key: the executor stores it but never interprets it (access policy is the consumer's job), so a background task started by one agent isn't readable cross-session. A required-but-nullable field makes a forgotten owner a visible `undefined` rather than a silently-unowned task.
+The seam is deliberately **task-free**: no task ids, no owner tokens, no polling protocol. Background-task semantics (ids, cross-session isolation, collect/stop tools, completion notices) live in the generic `ctx.tasks` runtime ([dsh-tasks](../../packages/tasks/tasks)); the tool layer adapts a `BashProcess` handle into a task registration, so a sandboxed or remote executor inherits no session or registry dependency.
-`stdin` and `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload on stdin and its `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` env. The model-facing `dsh-tool-bash` tool does not expose them as parameters — its request is built from `command`/`workdir`/`timeoutMs`/`signal`/`owner` only — because a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), so duplicating them as tool params would be redundant. This is NOT a security boundary: the credential scrub in `dsh-bash-local` is what stops the harness's ambient secrets reaching a spawned command, and it works regardless of these fields (a model cannot read a value the scrub removed, and tool-call args are static JSON, never shell-evaluated). A guard test asserts the tool doesn't forward model `env`/`stdin` — to catch a future `...args` spread, not to defend a trust wall. `env` is merged AFTER the scrub so an explicit caller entry (a value it already holds) wins even on a credential-shaped name. See [the bash-stdin-env RFC](../rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
-
-Both ids the seam handles are [branded](core.md) (zero-cost `string` brands, the same machinery as `SessionId`/`AgentId`): `BashTaskId` (a tracked background task, generated `bash-N` by the local executor) and `OwnerToken` (the opaque isolation key). `OwnerToken` is deliberately a DISTINCT brand from `SessionId`, not an alias: the bash seam is a capability seam that must not know what an owner token *means*, so it never imports `dsh-session`'s vocabulary — the `dsh-tool-bash` consumer is the single boundary that casts the owning agent's `SessionId` into an `OwnerToken`. Branding both stops a raw `string` (or a `BashTaskId` where an `OwnerToken` is expected, or vice versa) from slipping through the type checker on the model-facing `task_id` path.
+`stdin` and `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload on stdin and its `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` env. The model-facing `dsh-tool-bash` tool does not expose them as parameters — its request is built from `command`/`workdir`/`timeoutMs`/`signal` only — because a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), so duplicating them as tool params would be redundant. This is NOT a security boundary: the credential scrub in `dsh-bash-local` is what stops the harness's ambient secrets reaching a spawned command, and it works regardless of these fields (a model cannot read a value the scrub removed, and tool-call args are static JSON, never shell-evaluated). A guard test asserts the tool doesn't forward model `env`/`stdin` — to catch a future `...args` spread, not to defend a trust wall. `env` is merged AFTER the scrub so an explicit caller entry (a value it already holds) wins even on a credential-shaped name. See [the bash-stdin-env RFC](../rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
## Foreground runs: `BashRunResult`
@@ -122,29 +102,40 @@ interface CollectedOutput {
}
```
-## Background tasks: `BashTask`
+## Background processes: `BashProcess`
-A long-running command started with `start()` is tracked as a `BashTask`. `BashTaskStatus` is `'running' | 'completed' | 'killed'`; `done` resolves when the underlying process closes and never rejects.
+A long-running command started with `start()` returns a `BashProcess` **handle** — the only access path (no executor-level id lookup). `BashProcessStatus` is `'running' | 'completed' | 'killed'`; `done` resolves when the underlying process closes and never rejects (a spawn failure settles as `killed` with the error readable on stderr). Reads stay valid after exit: the remaining buffered output is still consumable through the handle.
```ts type-equiv
-interface BashTask {
- readonly id: BashTaskId
+interface BashProcess {
+ /** The command line this process runs. */
readonly command: string
- status: BashTaskStatus
+ /** Process lifecycle state (settled exactly once). */
+ status: BashProcessStatus
/** Exit code once finished (null = killed by signal / still running). */
exitCode: number | null
/** Terminating signal name, when signal-killed. */
signal: NodeJS.Signals | null
- /** Resolves when the underlying process closes (never rejects). */
+ /** Resolves when the underlying process closes (never rejects — a spawn failure settles as `killed` with the error on stderr). */
readonly done: Promise
+ /**
+ * Read output produced since the previous read (consuming — consecutive
+ * reads never re-deliver). Reads that lost data flag `lossy` and point at
+ * full-stream spill files when available.
+ */
+ readOutput(): BashProcessRead
+ /**
+ * Kill the process group. Returns false when it had already finished
+ * (no-op); idempotent.
+ */
+ kill(): boolean
}
```
-`readOutput()` returns an incremental `BashTaskRead` — the output produced since the previous read, with a `lossy` flag when truncation dropped unread bytes:
+`readOutput()` returns an incremental `BashProcessRead` — the output produced since the previous read, with a `lossy` flag when truncation dropped unread bytes:
```ts type-equiv
-interface BashTaskRead {
- task: BashTask
+interface BashProcessRead {
/** Output produced since the previous read (stderr in a marked section). */
delta: string
/** True when truncation dropped unread bytes the delta cannot include. */
@@ -158,4 +149,4 @@ interface BashTaskRead {
## The service
-`BashExecutor` (`ctx.bash`, abstract — defined in [`packages/bash/bash/src/index.ts`](../../packages/bash/bash/src/index.ts)) mirrors the `LlmService`/`LlmAdapter` split: `resolve` (request → spec), `run` (foreground), `start` (background), `get`/`ownerOf`/`list`/`readOutput`/`kill`, and `onTaskDone` (a `BashTaskListener` completion callback). Spawned commands get a **scrubbed env** (dropping `*KEY*`/`*SECRET*`/`*TOKEN*`) and spill files use a private 0700 dir with random names and owner-only opens — model output never gets the ambient environment or a predictable path. The implementation that provides all this is `dsh-bash-local`; the model-facing `bash`/`bash_output`/`bash_kill` schemas that call it are in `dsh-tool-bash` (and present as terminals via the [tool-presentation vocabulary](tools.md#tool-presentation-ui-vocabulary)).
+`BashExecutor` (`ctx.bash`, abstract — defined in [`packages/bash/bash/src/index.ts`](../../packages/bash/bash/src/index.ts)) mirrors the `LlmService`/`LlmAdapter` split and is exactly three methods: `resolve` (request → spec), `run` (foreground), `start` (background, returning the `BashProcess` handle). Spawned commands get a **scrubbed env** (dropping `*KEY*`/`*SECRET*`/`*TOKEN*`) and spill files use a private 0700 dir with random names and owner-only opens — model output never gets the ambient environment or a predictable path. The implementation that provides all this is `dsh-bash-local`; the model-facing `bash` schema that calls it is in `dsh-tool-bash` (background runs register with [`ctx.tasks`](../../packages/tasks/README.md) and are collected via the generic `task_output`/`task_kill`), presenting as terminals via the [tool-presentation vocabulary](tools.md#tool-presentation-ui-vocabulary).
diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md
index 615c222d94..91667a304e 100644
--- a/docs/core-data-structures/core.md
+++ b/docs/core-data-structures/core.md
@@ -19,7 +19,8 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t
| [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnTrigger`/`TurnEndReason`, `deriveMessages()`, the turn-enclosure invariant |
| [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, JSONL + SQLite backends, `session/flush`, crash recovery, `SessionHeader` |
| [tools.md](tools.md) | `ToolDefinition` full fields, the schema DSL, `ToolExecution`/`ToolResult`, tool-presentation UI types, the `tools/pre-execute`/`tools/post-execute` pipeline |
-| [bash.md](bash.md) | the bash executor seam: `BashExecRequest`/`Spec`, `BashRunResult`, background `BashTask`s |
+| [bash.md](bash.md) | the bash executor seam: `BashExecRequest`/`Spec`, `BashRunResult`, the background `BashProcess` handle |
+| [tasks.md](tasks.md) | the background task runtime: `TaskId`, `TaskRegistration`, `TaskOutcome`, `TaskSnapshot`/`TaskRead`, owner isolation, the control-tool surface |
| [code-runtime.md](code-runtime.md) | the code-execution seam: `CodeRunRequest`/`Result`, binding namespaces, captured logs, the `CodeRunFailure` taxonomy |
| [filesystem.md](filesystem.md) | the filesystem seam: `FsTarget`, read/write/edit outcomes, observed-file state, `FsErrorCode` |
| [compaction.md](compaction.md) | the compaction seam: the `compact/*` session events, `CompactionResult`, the `CompactService` interface |
@@ -68,7 +69,7 @@ Two large discriminated unions are the ones consumers `switch` over most: **`Str
IDs that cross package boundaries are **branded** — structurally strings, but non-interchangeable at the type level (an `AgentId` can't be passed where a `CallId` is expected). Construction goes through a per-type factory; comparison, logging, and JSON behave as ordinary strings.
-The `Branded` primitive lives in its own type-only package, [dsh-brand](../../packages/util/brand) (no runtime code, no harness-package dependency), so any package can brand the ids it owns without depending on an unrelated capability package (e.g. dsh-bash brands `BashTaskId`/`OwnerToken` via dsh-brand alone, never pulling in dsh-llm).
+The `Branded` primitive lives in its own type-only package, [dsh-brand](../../packages/util/brand) (no runtime code, no harness-package dependency), so any package can brand the ids it owns without depending on an unrelated capability package (e.g. dsh-tasks brands `TaskId` via dsh-brand alone, never pulling in dsh-llm).
Source: [`packages/util/brand/src/index.ts`](../../packages/util/brand/src/index.ts)
@@ -76,7 +77,7 @@ Source: [`packages/util/brand/src/index.ts`](../../packages/util/brand/src/index
type Branded = string & { readonly [BRAND]: B }
```
-The three core IDs: `CallId` (correlates a tool call with its result; dsh-llm), `SessionId` (dsh-session), `AgentId` (dsh-agent). Each is `Branded<'CallId'>` etc. plus a same-named factory function. Capability seams brand their own ids too — see `BashTaskId`/`OwnerToken` in [bash.md](bash.md).
+The three core IDs: `CallId` (correlates a tool call with its result; dsh-llm), `SessionId` (dsh-session), `AgentId` (dsh-agent). Each is `Branded<'CallId'>` etc. plus a same-named factory function. Capability seams brand their own ids too — see `TaskId` in [tasks.md](tasks.md).
## Content blocks and messages
diff --git a/docs/core-data-structures/tasks.md b/docs/core-data-structures/tasks.md
new file mode 100644
index 0000000000..f229e402ee
--- /dev/null
+++ b/docs/core-data-structures/tasks.md
@@ -0,0 +1,126 @@
+# Background Task Runtime
+
+The shared background-task vocabulary — what a producer (`dsh-tool-bash`, `dsh-tool-subagent`, any future long-running tool) hands to `ctx.tasks.register()` and what consumers (the `task_output`/`task_list`/`task_kill` tools, completion-notice injection) get back. The runtime is ONE concrete service ([dsh-tasks](../../packages/tasks/tasks), `ctx.tasks`), not an interface/implementation seam pair — see [the runtime RFC](../rfc/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) for the decision and [the tasks group README](../../packages/tasks/README.md) for the package split.
+
+Source: [`packages/tasks/tasks/src/types.ts`](../../packages/tasks/tasks/src/types.ts)
+
+## Ids and status
+
+`TaskId` is [branded](core.md#branded-ids) (`Branded<'TaskId'>` + a same-named factory), generated by the registry as `-N` with a per-kind counter (`bash-1`, `subagent-1`) — kind-prefixed so transcripts stay self-describing, sequential because the owner fence (not id secrecy) is the isolation boundary. `TaskStatus` is generic and CLOSED: `'running' | 'stopping' | 'completed' | 'killed' | 'failed'` — kind-specific meaning (exit codes, stop reasons) rides in `TaskSnapshot.detail`, so the registry never learns process or agent semantics.
+
+## The producer contract: `TaskRegistration`
+
+A producer starts its work, then hands the running work over. The producer stays the owner of its execution concerns (process streams, child agents); the registry owns ids, isolation, status, and completion fan-out. The optional `readOutput` marks a STREAM kind — the method presence is the capability, mirroring `SubagentRun.sendMessage`.
+
+```ts type-equiv
+interface TaskRegistration {
+ /** Producer kind — also the id prefix (`bash`, `subagent`, …). Non-empty. */
+ kind: string
+ /** One-line model-facing label (the command; the delegation description). */
+ label: string
+ /**
+ * The spawning agent. Its `session.header.id` becomes the task's owner
+ * token (read/kill/wait/list are fenced to that session), and its disposal
+ * cancels and awaits the task through the `ctx.agents.onCleanup` seam.
+ * `undefined` registers an UNOWNED task: open to any caller, alive until the
+ * tasks service disposes.
+ */
+ owner?: Agent | undefined
+ /**
+ * Request termination. Idempotent, synchronous, and must lead to
+ * {@link done} settling; a throw propagates to the killer (fail loud — a
+ * cancel that cannot even be requested is a producer bug). The optional
+ * reason is `task_kill`'s logged reason, forwarded verbatim.
+ */
+ cancel(reason?: string): void
+ /**
+ * Settles with the terminal outcome at QUIESCENCE — after the producer has
+ * released the task's resources (process exited, child agent disposed) —
+ * not merely when the work finished. Must never reject; a rejection is
+ * contained as a `failed` outcome and logged as a producer contract
+ * violation.
+ */
+ done: Promise
+ /**
+ * OPTIONAL incremental read (stream kinds): everything produced since the
+ * previous call, formatted by the producer (truncation/spill notices
+ * included). Consecutive calls never re-deliver output; the registry keeps
+ * ONE consuming cursor per task, so v1's single intended reader is the
+ * owning model. Absence marks a final-output-only kind (the method presence
+ * IS the capability).
+ */
+ readOutput?(): string
+}
+```
+
+`register()` is ATOMIC: a throw (the no-control-surface fence, an owner-cleanup attach failure) mutates no registry state, so the producer cancels and awaits its just-started work and rethrows — background work never runs without a collectable id.
+
+```ts type-equiv
+interface TaskOutcome {
+ /** How the task ended: finished (`completed`), cancelled (`killed`), or broke (`failed`). */
+ status: 'completed' | 'killed' | 'failed'
+ /** Kind-specific detail rendered into status lines ('exit code: 3', 'max-tokens'). */
+ detail?: string
+ /**
+ * Final output for FINAL-OUTPUT-ONLY kinds (no {@link TaskRegistration.readOutput}),
+ * read idempotently after the task settles. Stream kinds leave it unset —
+ * their output is consumed incrementally through `readOutput`.
+ */
+ output?: string
+}
+```
+
+## What consumers see: `TaskSnapshot` and `TaskRead`
+
+Snapshots are fresh projections, never live registry state. `reported` is the notice-suppression flag: the completion-notice injector (`dsh-tool-tasks`) skips a task whose terminal state the model already saw.
+
+```ts type-equiv
+interface TaskSnapshot {
+ /** The registry-issued id (`-N`). */
+ id: TaskId
+ /** The producer kind the task was registered with. */
+ kind: string
+ /** The producer-supplied one-line label. */
+ label: string
+ /**
+ * The owner's session id (`session.header.id`), for surfaces that must
+ * reach the owning agent (the completion-notice injector); absent for
+ * unowned tasks. Session ids are runtime-shared identifiers, not secrets —
+ * the read/kill/wait/list FENCE is what isolation rests on.
+ */
+ ownerSession?: string
+ /** Current lifecycle state. */
+ status: TaskStatus
+ /** Kind-specific status detail, present once the producer supplied one (usually terminal). */
+ detail?: string
+ /** Epoch ms when the task was registered. */
+ startedAt: number
+ /** Epoch ms when the task settled; absent while `running`/`stopping`. */
+ finishedAt?: number
+ /**
+ * True once the terminal state has been (or is being) reported to the owner
+ * through an explicit surface response — a `kill` call, or a `read`/`wait`
+ * that returned the terminal state (including a wait pending at settlement).
+ * Completion-notice surfaces suppress their notice when set, so the model
+ * never gets a redundant "finished" for a task it just collected or killed.
+ */
+ reported: boolean
+}
+```
+
+```ts type-equiv
+interface TaskRead {
+ /**
+ * Stream kinds: the consuming delta since the previous read. Final-output
+ * kinds: empty while live, the terminal {@link TaskOutcome.output} (or
+ * empty) once settled — idempotent, never consumed.
+ */
+ text: string
+ /** The task's state at read time. */
+ snapshot: TaskSnapshot
+}
+```
+
+## The service
+
+`TaskService` (`ctx.tasks` — [`packages/tasks/tasks/src/index.ts`](../../packages/tasks/tasks/src/index.ts)): `register` (atomic, fenced by `attachSurface`), non-consuming `get`/`list` (caller-scoped — owned-by-caller plus unowned only), `read` (consuming for stream kinds), `kill` (producer `cancel` first; a throw leaves the task untouched), `wait` (bounded, abort cancels the wait only), and `onTaskDone` (a `TaskDoneListener` per settlement, effect-scoped, contained). Every read/kill/wait/get compares the task's owner session with the caller's and rejects a foreign one. Owned tasks are cancelled and awaited when their owning agent disposes (the `ctx.agents.onCleanup` seam); the model-facing surface over all of this is [dsh-tool-tasks](../../packages/tasks/tool-tasks/README.md).
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index c10d7caa52..8aef863ee0 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -25,10 +25,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `session/created` | `emit` | [`packages/core/session/src/index.ts:39`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) |
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:57`](../packages/core/session/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`parallel`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
-| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:98`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) |
-| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:72`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:83`](../packages/subagent/subagent/src/index.ts) | - | [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:91`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) |
+| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:99`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) |
+| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:73`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:84`](../packages/subagent/subagent/src/index.ts) | - | [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:92`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) |
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:38`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | - |
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:44`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:97`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
diff --git a/docs/module-graph.md b/docs/module-graph.md
index 5e043d22d4..e7f77dce7a 100644
--- a/docs/module-graph.md
+++ b/docs/module-graph.md
@@ -82,6 +82,10 @@ flowchart TD
subgraph group_code_runtime["packages/code-runtime"]
pkg_code_runtime["code-runtime"]
end
+ subgraph group_tasks["packages/tasks"]
+ pkg_tasks["tasks"]
+ pkg_tool_tasks["tool-tasks"]
+ end
pkg_llm --> pkg_brand
pkg_bash --> pkg_brand
pkg_llm_deepseek --> pkg_llm
@@ -124,6 +128,8 @@ flowchart TD
pkg_invariants --> pkg_agent
pkg_invariants --> pkg_llm
pkg_invariants --> pkg_session
+ pkg_tasks --> pkg_agent
+ pkg_tasks --> pkg_brand
pkg_agent_loop --> pkg_agent
pkg_agent_loop --> pkg_llm
pkg_agent_loop --> pkg_session
@@ -134,6 +140,7 @@ flowchart TD
pkg_tool_bash --> pkg_bash
pkg_tool_bash --> pkg_llm
pkg_tool_bash --> pkg_system_prompt
+ pkg_tool_bash --> pkg_tasks
pkg_tool_bash --> pkg_tools
pkg_tool_fs --> pkg_fs
pkg_tool_fs --> pkg_llm
@@ -160,13 +167,19 @@ flowchart TD
pkg_acp --> pkg_session
pkg_acp --> pkg_session_persistence
pkg_acp --> pkg_tools
+ pkg_tool_tasks --> pkg_agent
+ pkg_tool_tasks --> pkg_system_prompt
+ pkg_tool_tasks --> pkg_tasks
+ pkg_tool_tasks --> pkg_tools
pkg_agent_core --> pkg_agent
pkg_agent_core --> pkg_agent_loop
pkg_agent_core --> pkg_invariants
pkg_agent_core --> pkg_llm
pkg_agent_core --> pkg_session
pkg_agent_core --> pkg_system_prompt
+ pkg_agent_core --> pkg_tasks
pkg_agent_core --> pkg_tool_bash
+ pkg_agent_core --> pkg_tool_tasks
pkg_agent_core --> pkg_tools
pkg_subagent_acp --> pkg_agent
pkg_subagent_acp --> pkg_llm
@@ -180,6 +193,7 @@ flowchart TD
pkg_tool_subagent --> pkg_agent
pkg_tool_subagent --> pkg_llm
pkg_tool_subagent --> pkg_subagent
+ pkg_tool_subagent --> pkg_tasks
pkg_tool_subagent --> pkg_tools
pkg_hooks_claude --> pkg_agent
pkg_hooks_claude --> pkg_hook_protocol
@@ -239,18 +253,20 @@ flowchart TD
| [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) |
| [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) |
| [`invariants`](../packages/support/invariants) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
+| [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand) |
| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
-| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
+| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`tools`](../packages/core/tools) |
| [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) |
| [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
| [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
| [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) |
-| [`agent-core`](../packages/core/agent-core) | `core` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/bash/tool-bash), [`tools`](../packages/core/tools) |
+| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
+| [`agent-core`](../packages/core/agent-core) | `core` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools) |
| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent) |
| [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
-| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
+| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
| [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
| [`subagent-mock`](../packages/support/subagent-mock) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent) |
| [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md
index 5f0da6a7ff..8b09679f36 100644
--- a/docs/rfc/INDEX.md
+++ b/docs/rfc/INDEX.md
@@ -13,7 +13,6 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
| [Code Mode — the model writes TypeScript against the tool registry](proposed/feature/2026-06-15-code-mode.md) | 2026-06-15 |
| [Pre-tool input rewrite — a consistent design](proposed/feature/2026-06-30-pre-tool-input-rewrite.md) | 2026-06-30 |
| [Claude Code and Codex subagent backends (out-of-process delegation to external coding agents)](proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md) | 2026-07-07 |
-| [Background subagent tasks](proposed/feature/2026-07-08-background-subagent-tasks.md) | 2026-07-08 |
| [Repeat-tool-call guard plugin](proposed/feature/2026-07-08-repeat-tool-guard.md) | 2026-07-08 |
### Simplification
@@ -28,7 +27,6 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
| Title | First proposed |
|---|---|
| [Runtime schemas for the event vocabulary (Zod vs the merge-extensible-map pattern)](proposed/architecture/2026-06-16-typed-event-schemas.md) | 2026-06-16 |
-| [Extract a generic long-running tool runtime](proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md) | 2026-06-20 |
### Process
@@ -64,6 +62,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
| [SessionStore fork API](implemented/feature/2026-06-30-session-store-fork-api.md) | 2026-06-30 |
| [Subagent lifecycle enrichment — lastAssistantMessage (observe-only)](implemented/feature/2026-06-30-subagent-observe-enrich.md) | 2026-06-30 |
| [Explicit model-facing tool order](implemented/feature/2026-07-06-explicit-tool-order.md) | 2026-07-06 |
+| [Background subagent tasks](implemented/feature/2026-07-08-background-subagent-tasks.md) | 2026-07-08 |
### Simplification
@@ -111,6 +110,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
| [Shared persistence write coordinator](implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md) | 2026-06-18 |
| [Branded IDs everywhere they belong](implemented/architecture/2026-06-20-branded-ids.md) | 2026-06-20 |
| [Extract example apps into packages](implemented/architecture/2026-06-20-extract-example-app-packages.md) | 2026-06-20 |
+| [The background task runtime (`ctx.tasks`) and the generic task control tools](implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) | 2026-06-20 |
| [Reorganize packages into a modular hierarchy](implemented/architecture/2026-06-20-package-hierarchy.md) | 2026-06-20 |
| [Mandatory `User-Agent` attribution for provider requests](implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md) | 2026-06-21 |
| [Web capability seam - stable tools over multiple providers](implemented/architecture/2026-06-24-web-capability-seam.md) | 2026-06-24 |
diff --git a/docs/rfc/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md b/docs/rfc/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md
new file mode 100644
index 0000000000..a049df012f
--- /dev/null
+++ b/docs/rfc/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md
@@ -0,0 +1,171 @@
+# RFC: The background task runtime (`ctx.tasks`) and the generic task control tools
+
+Status: implemented
+
+## Problem
+
+The bash capability seam supports both foreground commands and long-running background tasks. Background support was large: the abstract executor exposed `start`, `get`, `ownerOf`, `list`, `readOutput`, `kill`, and `onTaskDone`; the local executor tracked tasks, incremental reads, owner tokens, process cleanup, and completion listeners; the model saw three tools (`bash`, `bash_output`, `bash_kill`); the tool plugin injected completion notices back into the owning agent's session. The local executor fenced task access behind owner tokens because predictable global task ids are a cross-session read/kill hazard.
+
+The [tool cookbook](../../../cookbook/adding-a-tool.md) already pointed at the real design smell: background bash is really generic long-running-tool infrastructure living inside one tool. The pressure stopped being hypothetical with [background subagent tasks](../feature/2026-07-08-background-subagent-tasks.md), which needs the same task ids, owner isolation, polling, stop, completion notices, and prompt guidance, and whose first draft answered by cloning the protocol under new names (`subagent_wait`, `subagent_output`, `subagent_stop`) and reshaping `dsh-tool-subagent` into a multi-tool plugin solely so the cloned companion tools would not collide across instances. Every future long-running capability (dev servers, watchers, remote jobs) would clone it again, and the model would learn a new collect/stop habit per capability.
+
+The surveyed peer products converged on the opposite shape. Claude Code exposes one `TaskOutput`/`TaskStop` pair spanning seven task kinds (background shells, subagents, remote sessions, …), with its earlier per-capability `BashOutput`/`KillShell` names kept only as aliases; Kimi Code's `BackgroundManager` runs process, agent, and pending-question kinds behind the same two tools and a ~5-method producer interface; DeepSeek-Reasonix serves bash and delegation from one session-scoped jobs manager; OpenCode's `BackgroundJob` registry is kind-agnostic by construction. The lesson is that the task registry, the control tools, and the notification path are one capability, and the producers (bash, subagents) are plugins into it.
+
+## Decision
+
+The `tasks/` package group owns background-task semantics once, and bash and subagents are producers:
+
+- `@deepseek-ai/dsh-tasks` — the task registry service (`ctx.tasks`): branded task ids, owner-scoped authorization, status snapshots, incremental/final output reads, cancellation, wait-for-terminal, completion listeners, and the awaited owner-cleanup path.
+- `@deepseek-ai/dsh-tool-tasks` — the model-facing control surface: `task_output`, `task_list`, `task_kill`, the completion-notice injection into the owning session, and the system-prompt section that teaches the background-task habit.
+
+Producers register running work into `ctx.tasks` and stay owners of their execution concerns: `dsh-tool-bash`'s `run_in_background` path registers the process it started (incremental stdout, spill formatting, kill), and `dsh-tool-subagent`'s background mode ([the feature RFC](../feature/2026-07-08-background-subagent-tasks.md)) registers the child run (final output only, cancel + dispose). The bash seam carries no registry: `bash_output`/`bash_kill` no longer exist (the generic tools replaced them), and the subagent companion tools were never created. The `dsh-agent-core` bundle loads the pair, so every shipped deployment has the control surface.
+
+The registry is a CONCRETE service, not an interface/implementation seam pair: there is exactly one sensible in-process implementation today, and the capability-seam convention says not to split preemptively. The pre-release stance lets a later durable/remote job system extract an interface when a second backend actually exists.
+
+## Task model
+
+`dsh-tasks` owns the vocabulary ([data-structure catalog](../../../core-data-structures/tasks.md)). `TaskId` is branded, generated by the registry as `-N` with a per-kind counter (`bash-1`, `subagent-1`) — the kind prefix keeps ids self-describing in transcripts and preserves the pre-runtime `bash-N` shape. Ids are runtime-global and predictable, so every access is authorized (below).
+
+A producer registers a task with:
+
+```ts ignore-check
+interface TaskRegistration {
+ /** Producer kind — also the id prefix ('bash', 'subagent', …). */
+ kind: string
+ /** One-line model-facing label (the command; the delegation description). */
+ label: string
+ /** The spawning agent; undefined = unowned (open access, dies with the service). */
+ owner?: Agent
+ /** Request termination; idempotent; must lead to `done` settling. The optional reason is `task_kill`'s logged reason, forwarded. */
+ cancel(reason?: string): void
+ /** Settles at QUIESCENCE — after the producer has released the task's resources. Never rejects. */
+ done: Promise
+ /** OPTIONAL incremental read (stream kinds). Consecutive calls never re-deliver output; the producer owns truncation/spill formatting. Absence = final-output-only kind. */
+ readOutput?(): string
+}
+
+interface TaskOutcome {
+ status: 'completed' | 'killed' | 'failed'
+ /** Kind-specific detail rendered into the status line ('exit code: 3', 'max-tokens'). */
+ detail?: string
+ /** Final output for final-only kinds; read idempotently after the task settles. */
+ output?: string
+}
+```
+
+The task status vocabulary is generic and closed: `running`, `stopping` (cancel requested, not yet settled), and the three terminal values above. Kind-specific meaning rides in `detail`, so the registry never learns process or agent semantics — the method presence (`readOutput`) is the capability, mirroring `SubagentRun.sendMessage`.
+
+The registry attaches ONE continuation to `done`: record the terminal snapshot, then notify task-done listeners with per-listener containment (the guarantee the bash seam's `notifyTaskDone` used to give its own listener set). `done` settling at quiescence — not merely at completion — is what makes owner cleanup and service disposal awaitable without a second completion surface; this resolves the old seam's duplication of a per-task `done` promise AND a global `onTaskDone` registry by making the promise the producer contract and the listener registry the consumer surface.
+
+Registrations are NOT effect-scoped to the registering fiber: a task belongs to its owning agent and its producing backend, not to the tool plugin whose call started it, so an HMR reload of `dsh-tool-bash` or `dsh-tool-tasks` never orphans or kills a running task (the same argument that used to keep bash ownership in the executor). The registry's own disposal cancels every live task and awaits settlement — no orphans survive `fiber.dispose()`.
+
+## Authorization and the service surface
+
+Cross-session isolation lives IN the runtime so every consumer gets the same rule for free: read/kill/wait/get take the caller (`Agent | undefined`), and a task whose owner session differs from the caller's session is rejected (`!== undefined` comparison — an unowned task is open, a no-agent caller cannot match an owned task). `list(caller)` returns only the caller-visible tasks (owned-by-caller or unowned) — a global listing would leak other sessions' labels. Owner identity is `session.header.id`, the canonical id every other subsystem keys on; because both sides of the comparison come from live `Agent`s, the freestanding `OwnerToken` brand the bash seam used to carry became internal state rather than a seam type.
+
+```ts ignore-check
+class TaskService extends Service { // ctx.tasks
+ register(reg: TaskRegistration): TaskId // throws when no control surface is attached; ATOMIC — a throw mutates nothing
+ get(id: TaskId, caller?: Agent): TaskSnapshot // non-consuming; throws: unknown id, foreign owner
+ list(caller?: Agent): TaskSnapshot[] // caller-visible only
+ read(id: TaskId, caller?: Agent): TaskRead // delta (stream kinds, consuming) or final output (final kinds, idempotent) + snapshot
+ kill(id: TaskId, caller?: Agent, reason?: string): 'requested' | 'already-terminal'
+ wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise
+ onTaskDone(listener: (snapshot: TaskSnapshot) => void): () => void // effect-scoped, contained, never fires after dispose
+ attachSurface(name: string): () => void // the misconfiguration fence, below
+}
+```
+
+`TaskSnapshot` is the read-only projection: id, kind, label, owner session, status, detail, started/finished timestamps, and the `reported` notice-suppression flag (below). `wait` resolves with the terminal snapshot, or with the still-`running` snapshot on timeout; aborting the wait cancels only the wait.
+
+**Misconfiguration fails loud**: a deployment that loads a background-capable producer without any control surface would let the model start tasks it can never read or stop — the half-loaded failure mode the subagent RFC's first draft reshaped a whole plugin to avoid. The fence is `attachSurface()`: `dsh-tool-tasks` attaches (effect-scoped) on load, and `register()` throws `background tasks unavailable: no control surface is attached (load @deepseek-ai/dsh-tool-tasks)` when none is attached — the earliest self-contained moment, since concurrent plugin start makes a load-time check racy. The registry stays ignorant of tool names; a deployment with a custom (non-model) surface attaches its own.
+
+## The model-facing control tools
+
+`dsh-tool-tasks` registers three kind-agnostic tools (ACP render intent: `generic` cards, `kind: 'execute'` for kill and `'read'` for output/list, no `locations`):
+
+- `task_output(task_id, wait?, timeout_ms?)` — non-blocking by default: stream kinds return output produced since the previous read, final kinds return only a status line while running and the final output once terminal; every response ends with the status line (`[status: running]`, `[status: completed, exit code: 0]`, `[status: failed, max-tokens]` — generic status + producer detail). `wait: true` blocks until the task settles or the timeout expires (config: defaulted `waitTimeoutMs`, capped `maxWaitTimeoutMs`); a timed-out wait returns `[status: running]` and leaves the task alive. Polling-by-default preserves the established bash habit; `wait` is what a parent uses when it is genuinely blocked on a subagent's answer.
+- `task_list()` — the caller's tasks, one line each: ` [] — `; `(no background tasks)` when empty. Most peers make listing a human-only surface (`/tasks` panels) and only Gemini CLI ships a model-facing list; DSH keeps it model-facing because the harness is an SDK with no guaranteed user UI — a deployment may have no `/tasks` equivalent — and a caller-scoped list is one cheap registry read.
+- `task_kill(task_id, reason?)` — requests cancellation and returns immediately (`requested cancellation of task `); the optional `reason` lands in the logged tool args and is forwarded to the producer's `cancel` where the underlying seam accepts one (`SubagentRun.cancel(reason)`). Killing an already-terminal task reports its terminal status rather than failing; a producer `cancel` that throws fails the call loud and leaves the task untouched (still `running`, notice not suppressed).
+
+`task_output`'s read cursor is task-scoped and CONSUMING for stream kinds: the registry keeps one cursor per task, and a read returns everything produced since the previous read, exactly like the old `bash_output`. v1's intended reader is the owning model — the owner fence already makes it the only model-facing one — so a non-consuming observation surface (a UI tailing a task, multiple concurrent readers) is deliberately out of scope; when one is needed, it extends the registry with a cursor/snapshot read API rather than changing `task_output`, because two consumers sharing the consuming cursor would silently eat each other's output.
+
+One system-prompt section (order 106, next to `tool:bash`) teaches the cross-call habit the per-tool descriptions cannot: track every returned task id; you are notified in-session when a task finishes, so do not busy-poll or sleep on one — keep working on independent steps and do not duplicate a running task's work; do not produce a final answer while a relevant task still runs — call `task_output` (with `wait` when blocked) to collect it first; `task_kill` tasks that stopped mattering. The do-not-poll and do-not-duplicate sentences are near-verbatim convergent across Claude Code, Kimi Code, and OpenCode — they are the two failure modes every peer engineered against.
+
+Completion notices stay durable context, not a wake-up (`agent.inject()` appends a logged `context/message` the next model request sees; it does not run the model): on `onTaskDone`, `dsh-tool-tasks` injects `background task (: ) finished [status: …]. Read its output with task_output.` into the owning agent's session, with the same disposed-race containment `dsh-tool-bash` used to carry. Notices are deduplicated the way Claude Code and Kimi Code both learned to: a task the model explicitly killed, or whose terminal state a read/wait already returned (including a wait pending at the moment of settlement), is marked `reported` and its notice suppressed — never a redundant "finished" for work the model just collected or ended. The model-visible ⟺ logged invariant holds with no new session event type.
+
+## Producer opt-in and schema exposure
+
+Whether a producer tool offers `run_in_background` is that producer's own defaulted config: `enableRunInBackground?: boolean` on `dsh-tool-bash` and on each `dsh-tool-subagent` instance (both default `true` — bash keeps its always-exposed behavior, and a deployment disables either per instance from cordis.yml, no code edit). A disabled producer omits the parameter from its schema entirely, so schema and capability can never disagree. `ctx.tasks` plays no part in schema shaping — it never rewrites or decorates a producer's tool schema (Kimi Code regex-rewrites its bash description when background is disabled; config-owns-the-schema makes that trick unnecessary) — it only provides runtime registration. The two halves compose fail-loud: the producer's config decides what the model sees, and a background call that still reaches `register()` without a control surface throws the load-this-package error. `register()` is atomic (a throw mutates no registry state), and a producer whose registration fails cancels and awaits its just-started work before rethrowing — background work never runs without a collectable id.
+
+## The awaited owner-cleanup seam
+
+A background task must not outlive its owner: the subagent case leaks live child agents/sessions otherwise, and `agent/disposed` is emitted synchronously inside the disposal chain without awaiting listener work, so an emit listener cannot promise quiescence (the analysis in [the feature RFC](../feature/2026-07-08-background-subagent-tasks.md)). The runtime therefore needs a seam the owning agent's disposal chain actually awaits, and that seam belongs to `dsh-agent`, where every lifecycle consumer can reach it:
+
+- `AgentRegistry.onCleanup(agentId, cleanup: () => Promise): () => void` — a per-agent cleanup registry (registrations are effects; the disposer unregisters).
+- The loop's composite disposal chain carries one link for it: after stop-and-drain and before unregister, `await ctx.agents.drainCleanups(agent.id)` runs every registered cleanup with per-cleanup containment (a throwing cleanup is logged and never starves later cleanups or the rest of the chain). This is a documented `dsh-agent-loop` change; running cleanups is part of the `AgentFactory` dispose contract so a replacement loop honors it too.
+
+`dsh-tasks` consumes the seam: the first task registered for an owner attaches one cleanup that cancels the owner's still-live tasks, awaits each task's `done` (quiescence), and drops the owner's snapshots. `AgentHandle.dispose()` thus resolves only after the owner's background children are actually gone, and the guarantee composes transitively: a background subagent that started background tasks of its own drains them when its child agent disposes inside the parent task's settlement path (the cascade OpenCode implements with explicit parent-chain walking falls out of the seam here). This is a deliberate behavior change for bash — a background bash task used to outlive its owning agent until service disposal — adopted for uniformity: an ownerless task is the sanctioned way to outlive an agent, and a future durable-job RFC is the way to outlive the runtime.
+
+## Bash migration
+
+`dsh-bash` keeps the execution contract and carries no registry. The seam is `resolve`, `run`, and `start`, where `start(spec)` returns a process handle — `BashProcess`: `{ command, status, exitCode, signal, done, readOutput(), kill() }` — instead of a registry entry: `get`/`ownerOf`/`list`/`onTaskDone`, the listener machinery, `BashTaskId`, `OwnerToken`, and the spec's `owner` field are gone (a consumer census found `get`/`list` reached only by test harnesses and `onTaskDone` single-consumer — `dsh-tool-bash`; the hook bridges consume `resolve`+`run` only). The local executor keeps an internal table of LIVE processes solely for its own disposal quiescence (entries leave on settlement). The foreground trusted-plugin path (`resolve` + `run` with `stdin`/`env`, used by the hook bridges) is untouched and never routes through the runtime; `BashExecSpec.timeoutMs` stays required-but-ignored by `start()` (shared-spec status quo, documented in the seam JSDoc); the credential-scrub duplication between the bash and ACP spawn sites is explicitly NOT this runtime's work — the registry never touches process spawning.
+
+`dsh-tool-bash` keeps the `bash` tool; the `run_in_background` path is `ctx.bash.start(...)` + `ctx.tasks.register({ kind: 'bash', label: command, owner: exec.agent, cancel, done, readOutput })`, where `done` maps the process exit to a `TaskOutcome` (`processOutcome`: `completed`/`killed` + exit-code/signal detail) and `readOutput` wraps the handle's incremental read with the spill/lossy formatting (`renderProcessRead`). The completion-notice listener left `dsh-tool-bash` entirely.
+
+## Subagent integration
+
+[Background subagent tasks](../feature/2026-07-08-background-subagent-tasks.md) rides this runtime; the headline consequence is that `dsh-tool-subagent` KEEPS its one-instance-per-provider shape — the multi-tool reshape existed only to keep cloned companion tools from colliding, and there are no companion tools to clone. Its background call starts the provider run, then registers `{ kind: 'subagent', label: description, owner: parent, cancel: run.cancel, done }` where `done` awaits `run.result`, awaits `run.dispose()` (quiescence), and maps the stop reason (`completed` → `completed`; `aborted` → `killed`; `error`/`max-tokens`/`refusal`/unknown → `failed` with the reason as detail) and the final text as `output`. No `readOutput` — the child session remains the detailed trace, exactly as that RFC argues.
+
+## Alternatives considered
+
+### Why not per-capability companion tools (`bash_output`/`bash_kill` + `subagent_wait`/`subagent_output`/`subagent_stop`)?
+
+That is the trajectory this RFC interrupted. Each capability re-implements ids, ownership, polling, stop, notices, and guidance; the model learns N collect/stop habits and the prompt carries N near-identical tool descriptions; `dsh-tool-subagent` needed a structural reshape purely to de-duplicate its clones. Claude Code walked this exact path — per-capability `BashOutput`/`KillShell` first, then a generalized `TaskOutput`/`TaskStop` spanning shells, agents, and remote sessions with the old names kept as deprecated aliases — and the pre-release stance let this repo land directly on the unified shape with no alias burden.
+
+### Why not an abstract `TaskRuntime` seam with swappable backends?
+
+There is one in-process implementation and no concrete second backend; the capability-seam rule is to split when the consumer and backend can actually evolve independently, not before. A durable/persistent job system is the plausible second backend, and it changes the lifecycle contract (survival across owner disposal) enough that its RFC should own the interface extraction.
+
+### Why not keep authorization in the consumers, as bash did?
+
+The bash split put policy in `dsh-tool-bash` so the executor seam stayed session-free — right for a seam that may be implemented remotely. The registry is harness-local infrastructure whose entire purpose includes the isolation fence; leaving the fence to each consumer means every future surface (model tools, a UI bridge, hook bridges) re-implements it or forgets it. Centralizing it is most of the reason the runtime exists.
+
+### Why not a `parallel`-mode `agent/cleanup` event instead of the keyed registry?
+
+An event fires for every agent at every disposal and every listener must filter; `Promise.all` rejection semantics need extra containment; and there is no disposer to make registrations effects. A keyed registry is targeted, contained, and disposable — and the loop already drains an ordered chain, so one more awaited link was the smaller change.
+
+### Why not blocking-by-default `task_output` (Claude Code's `block: true`)?
+
+The established bash habit is poll-between-work, and the guidance tells the model to keep doing independent work while tasks run; defaulting to block would silently serialize the parent on its slowest child. The explicit `wait: true` keeps blocking a deliberate act, and the wait/read/kill trio still lands within the three-tool surface.
+
+### Why not a separate `task_wait` tool?
+
+Waiting is never useful without reading the result afterwards; a separate tool doubles the calls and the schema surface for zero information. Folding it into `task_output` matches the only real usage pattern.
+
+### Why not a push-sink producer contract (`appendOutput`/`settle`), as Kimi Code's manager uses?
+
+A sink centralizes output buffering, truncation, and spill in the runtime, which is elegant when the runtime owns output storage. In this codebase those concerns already live — bounded, tested, spill-file-aware — inside `dsh-bash-local`, and keeping process concerns in the executor is the point of the bash seam. The pull contract (`readOutput()` returning a formatted delta) reuses that machinery as-is; a sink would relocate it for no v1 gain. If a durable backend later makes the runtime own output storage, the producer contract is the one seam to revisit.
+
+### Why not random task ids (Claude Code's per-kind `36^8` suffixes)?
+
+Peers with shared registries use unguessable ids as defense-in-depth against cross-session access and predictable-path attacks. Here the owner fence is the boundary — exactly as `dsh-tool-bash` documented for the old predictable `bash-N` — and the registry hands no filesystem paths derived from the id, so sequential per-kind counters keep transcripts readable and tests deterministic. Nothing prevents switching the generator later; the id is branded and opaque to consumers.
+
+### Why not foreground→background promotion?
+
+Claude Code, Kimi Code, and OpenCode all let a running foreground call be promoted to a background task (user action or an over-budget auto-detach). It is deliberately out of v1: promotion needs a UI/user channel the SDK does not prescribe, and it changes the foreground tools' result contracts. The registration-based design leaves the door open — a foreground execution is promotable by registering its already-running work mid-flight — and a follow-up RFC can add it without touching the model-facing control tools.
+
+### Why not new session events for task lifecycle?
+
+Everything model-visible already lands in the log: starts and reads are tool calls/results, notices are injected `context/message` events. A `task/*` session event would duplicate facts the log carries, and live registry state (`task_list`) is intentionally runtime state, exactly as bash's task table was.
+
+## Testing
+
+Unit coverage pins the registry lifecycle (register/read/kill/wait/list, owner isolation including no-agent callers, stream-vs-final read semantics, listener containment, notice suppression after an explicit kill or terminal read/wait, the `attachSurface` fence, register atomicity — a failed registration mutates nothing and burns no counter; a failed producer `cancel` leaves the task untouched — disposal quiescence, per-kind id counters), the `onCleanup` drain ordering + containment (including mid-drain registration), both producers' registration mapping plus their no-orphan guarantee (a failed `register()` kills/cancels and awaits the just-started work before rethrowing), and unchanged foreground bash/subagent behavior. Snapshot coverage pins the task tool schemas and the prompt section through the pinned-header fixture.
+
+## Consequences
+
+One background-task contract exists instead of a per-capability clone family: a background subagent and a background bash command coexist in one session under one id namespace, one listing, one notice format, and one guidance section, and the [tool cookbook](../../../cookbook/adding-a-tool.md) points long-running tools at `ctx.tasks`. The cost was a wide landing change — the bash seam lost its registry surface and every test tier moved with it, model-facing tool names churned (`bash_output`/`bash_kill` deleted), and the ACP snapshot pinned header was refreshed for the new schemas — sanctioned by the pre-release stance.
+
+Owner-scoped cleanup changed bash semantics: a task that previously outlived its agent now dies with it. Deployments that relied on fire-and-forget background commands start them unowned (a non-agent caller) or accept the new lifecycle; the uniformity was judged worth the change, and the durable-job direction remains open for real survival requirements.
+
+`wait` is the first blocking tool call whose duration is model-controlled; the config cap bounds it, but a model that serializes on `wait` loses the parallelism the feature exists for — prompt guidance mitigates, and a future continuation-policy guard can enforce. Recording live background-flow snapshot scenarios (a polled and killed background command, a completion-notice turn) and a with-key e2e background lifecycle require a `DEEPSEEK_API_KEY` re-record and remain named follow-up work. The runtime deliberately defers durable/cross-restart tasks, non-consuming observation cursors, and foreground→background promotion (see Alternatives).
diff --git a/docs/rfc/implemented/feature/2026-07-08-background-subagent-tasks.md b/docs/rfc/implemented/feature/2026-07-08-background-subagent-tasks.md
new file mode 100644
index 0000000000..0c9c7f884e
--- /dev/null
+++ b/docs/rfc/implemented/feature/2026-07-08-background-subagent-tasks.md
@@ -0,0 +1,62 @@
+# RFC: Background subagent tasks
+
+Status: implemented
+
+## Problem
+
+The subagent seam ([the seam RFC](2026-06-21-subagent-capability-seam.md)) exposes `start() -> SubagentRun`, and the model-facing `dsh-tool-subagent` consumer collected that run synchronously only: the parent turn blocked until the child returned one final result. That shape is simple and transport-neutral, but it makes slow delegation expensive for the parent. A model that wants two independent investigations must either run them serially or hold the parent step open for the entire child duration.
+
+The harness already had one background-task precedent in bash: task ids, owner checks, output polling, stop, completion notifications, and prompt guidance. Subagents need the same user-facing habit, but not by copying bash's process-output semantics: a subagent does not expose an incremental stdout stream, and its child session remains the home for internal steps. The parent needs to start a child, keep working, later wait for or read the final answer, and stop the task when it is no longer relevant. An earlier draft of this RFC answered by cloning the bash protocol under subagent names (`subagent_wait`, `subagent_output`, `subagent_stop`) and reshaping `dsh-tool-subagent` into a multi-tool plugin so the clones would not collide across instances; [the background task runtime RFC](../architecture/2026-06-20-generic-long-running-tool-runtime.md) dissolved that duplication by extracting the registry and the control tools once, and this feature rides it.
+
+The design also has two lifecycle constraints that the synchronous path does not face. First, a background subagent can outlive the tool call that started it, so the tool-call abort signal must not stay wired to the child after the id is returned. Second, a completion notice can only be injected into a live owner agent: once an ACP session closes or an agent handle is disposed, `agent.inject()` cannot append to that session. The feature must therefore define whether background subagents survive owner disposal.
+
+## Decision
+
+Each `dsh-tool-subagent` instance may expose `run_in_background?: boolean`, gated per instance by its defaulted `enableRunInBackground` config flag (default `true`; a disabled instance omits the parameter from its schema — the producer-opt-in shape [the runtime RFC](../architecture/2026-06-20-generic-long-running-tool-runtime.md) pins: the producer's config owns the schema, `ctx.tasks` only provides runtime registration). The plugin keeps its one-instance-per-provider shape — provider selection stays deployment config (`subagent` on `spawn`, `subagent_fork` on `fork`, …), and there are no subagent-specific companion tools to collide: collection, listing, and cancellation are the generic `task_output`/`task_list`/`task_kill` tools from `@deepseek-ai/dsh-tool-tasks`.
+
+A foreground call keeps the synchronous semantics: it waits for `run.result`, returns final text on `completed`, maps non-clean terminal stop reasons to an errored tool result, and disposes the run in `finally`.
+
+A background call validates that a parent agent exists, checks an already-aborted tool signal before starting, starts the provider run through `ctx.subagents`, registers the run with `ctx.tasks`, and returns `started background subagent task `. After the id is returned the tool-call signal is NOT connected to `run.cancel()` — the parent step may finish while the child continues; cancellation belongs to `task_kill` and the owner-cleanup path. A registration that throws (the no-control-surface fence) does not orphan the child: the producer cancels the run, awaits its `done` (which settles only after `run.dispose()`), and rethrows — the model never learns an id for work that is not actually tracked. `ctx.tasks.register()` supplies the runtime guarantees this feature needs and this RFC does not implement: kind-prefixed branded task ids, owner-scoped access (the parent agent is the owner; another session's agent cannot read or kill the task), the loud no-control-surface failure, completion-notice injection, and the generic prompt guidance.
+
+The registration maps the seam vocabulary onto the runtime's:
+
+- `kind: 'subagent'`, `label`: the model's `description` argument, `owner`: the parent agent.
+- `cancel`: `run.cancel(reason)` — the runtime forwards `task_kill`'s optional logged `reason`; the task shows as `stopping` until settlement.
+- `done` (`settleRun`): awaits `run.result`, then awaits `run.dispose()` (child quiescence — `done` must not settle before the child agent/session is released), then maps the stop reason (`runOutcome`): `completed` → `completed` with the final text as `output`; `aborted` → `killed`; `error`, `max-tokens`, `refusal`, and unknown merge-extensible reasons → `failed` with the reason as `detail`. A rejected `run.result` (infrastructure fault) still disposes and settles `failed`.
+- No `readOutput`: a subagent task is final-output-only. While it runs, `task_output` returns only the status line; once terminal it returns the final text (or failure detail) idempotently. The child session remains the detailed trace; v1 deliberately exposes no incremental transcript cursor.
+
+## Lifecycle
+
+The background task is scoped to the owner session, not durable across session closure. The runtime's awaited owner-cleanup path (the `AgentRegistry.onCleanup` seam, owned by [the runtime RFC](../architecture/2026-06-20-generic-long-running-tool-runtime.md)) cancels the owner's running tasks on agent disposal and awaits each task's `done` before `AgentHandle.dispose()` resolves; because this registration's `done` settles only after `run.dispose()`, owner disposal reaches child quiescence without leaking child agents or sessions. `agent/disposed` alone is not the mechanism — the registry emits it synchronously without awaiting listener work, which is exactly why the awaited seam exists. Completion notices are best-effort by the runtime's rule: a live owner gets the injected notice; a disposed owner drops it without throwing.
+
+## Model guidance
+
+The background-task habit (track ids, do not finish while a relevant task runs, collect with `task_output`, kill what stopped mattering) is the generic `dsh-tool-tasks` prompt section — one habit for bash and subagents alike, which is the point of the shared runtime. `dsh-tool-subagent` adds only the wording on its own tool: the description and the `run_in_background` parameter say the call returns a task id immediately and the final answer is collected with `task_output` (with `wait: true` when genuinely blocked on it). Runtime enforcement remains owner authorization and the awaited owner-cleanup path, not the prompt.
+
+## Alternatives considered
+
+### Why not subagent-specific `subagent_wait`/`subagent_output`/`subagent_stop` tools?
+
+The earlier draft of this RFC. The clones duplicate the bash protocol, teach the model a second collect/stop habit, and force a structural reshape of `dsh-tool-subagent` (one multi-tool instance instead of one instance per provider) purely so the companion tools register once. The generic runtime provides the same operations kind-agnostically, keeps this plugin's shape untouched, and its `attachSurface` fence covers the half-loaded deployment failure the reshape was defending against. The reshape was dropped with the clones.
+
+### Why not let background subagents survive owner session closure?
+
+Survival after owner closure requires durable task state, child-session recovery, a way to surface late results into a reopened session, and policy for tasks whose owning client never returns. The agent runtime unregisters disposed agents, and `agent.inject()` intentionally rejects disposed targets. Scoping tasks to the owner and cleaning up through the awaited path makes the v1 lifecycle explicit and avoids orphaned child agents; a durable job system is the runtime RFC's named future direction, not this feature.
+
+### Why not skip owner checks because ACP sessions are isolated?
+
+ACP sessions isolate their logs and agents, but services such as `ctx.agents`, `ctx.tools`, and `ctx.tasks` are shared within the runtime, and task ids are global, predictable resource handles. The runtime enforces the owner fence for every task kind; this RFC merely notes that subagent tasks inherit it.
+
+### Why not expose incremental subagent transcript output?
+
+The child session is already the trace for internal reasoning, tool calls, and intermediate messages. Streaming that transcript into the parent would blur the parent/child log boundary that makes in-process and ACP providers equivalent. The first background surface returns status and final output only; richer observation belongs to UI/session tooling or a separate observation RFC.
+
+## Testing
+
+Unit coverage pins the stop-reason → outcome mapping (`runOutcome`, including unknown merge-extensible reasons), `settleRun`'s dispose-before-report on both result paths, the detached-signal contract (a pre-aborted signal refuses to start; a returned id is never wired to the tool signal), background settlement collected through the real `task_output`/`task_kill` tools, the no-orphan rollback when `register()` throws, per-instance schema gating (`enableRunInBackground: false` omits the parameter and the background wording), and the loud failure when the tasks runtime is absent. Snapshot coverage pins the changed `subagent`/`subagent_fork` schemas through the pinned-header fixture; recording a live background-delegation transcript requires a `DEEPSEEK_API_KEY` re-record and remains named follow-up work.
+
+## Consequences
+
+Slow delegation no longer holds the parent step open: the model fans out background children, keeps working, and collects with the same three control tools it already uses for bash — no new habit, no schema clones, and `dsh-tool-subagent`'s per-provider shape survived unchanged. The feature's usability depends on the tasks pair being loaded; the runtime's `register()` fence turns a missing control surface into a loud, actionable error rather than a silent dead end, and the `dsh-agent-core` bundle ships the pair so every stock deployment has it.
+
+The prompt guidance reduces abandoned tasks but cannot force a model to collect every background result. Runtime cleanup through the awaited owner-disposal path is the hard stop; a future planner or guard could enforce "no final answer with relevant running tasks" more strongly if the prompt proves insufficient. A background child outliving its starting tool call means a misbehaving child consumes tokens until collected, killed, or owner-disposed; `task_list` keeps it visible, and setting `enableRunInBackground: false` per instance keeps a deployment's delegation strictly synchronous.
diff --git a/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md b/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md
deleted file mode 100644
index f4ebe921b5..0000000000
--- a/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md
+++ /dev/null
@@ -1,41 +0,0 @@
-# RFC: Extract a generic long-running tool runtime
-
-Status: proposed
-
-## Problem
-
-The bash capability seam supports both foreground commands and long-running background tasks. Background support is large: the abstract executor exposes `start`, `get`, `ownerOf`, `list`, `readOutput`, `kill`, and `onTaskDone`; the local executor tracks tasks, incremental reads, owner tokens, process cleanup, and completion listeners; the model sees three tools (`bash`, `bash_output`, `bash_kill`); the tool plugin injects completion notices back into the owning agent's session. The local executor fences task access behind owner tokens because predictable global task ids are a cross-session read/kill hazard.
-
-The [tool cookbook](../../../cookbook/adding-a-tool.md) already points at the real design smell: background bash is really generic long-running-tool infrastructure living inside one tool. If future tools need background execution, polling, kill, ownership, and completion notices, those semantics should not be hidden in `dsh-bash`.
-
-## Proposal
-
-Move long-running task semantics above bash into a tool-agnostic runtime. Bash remains able to run background commands, but it stops owning the general concepts of task ids, ownership tokens, polling, cancellation, completion notifications, and model-facing "read/kill this task" commands.
-
-The runtime should own:
-
-- Stable task ids and owner tokens keyed to the calling session/agent.
-- Registration of a long-running task with a producer for incremental output and a completion promise.
-- Generic read/cancel/list operations with the same cross-session authorization rule for every tool.
-- Completion notification injection into the owning session.
-- Presentation hooks for pending/running/completed task state, with bash supplying only command-specific labels and output formatting.
-
-`dsh-bash` then keeps the bash-specific execution contract: resolve a request into a command spec, run a foreground command, or start a process and hand its streams/process handle to the generic runtime. `dsh-tool-bash` keeps the model-facing command tool, but the follow-up operations become generic long-running-tool operations or a shared utility that bash registers with, rather than bespoke `bash_output`/`bash_kill` plumbing.
-
-## Current seam consumption
-
-A consumer census of the surface the runtime would carve up. Production has two seam consumers: `packages/bash/tool-bash/src/index.ts` consumes `resolve`, `run`, `start`, `ownerOf`, `readOutput`, `kill`, and `onTaskDone`; and the hook bridges — via `dsh-hook-protocol`'s `runHook` (`packages/hooks/hook-protocol/src/runner.ts`) — consume `resolve` + `run` only, a foreground-only trusted-plugin caller that sets the seam's `stdin`/`env` fields, so the background machinery stays single-consumer (which sharpens the extraction premise). `get()`/`list()` have test-harness consumers only — they were removed once and reverted on the merits (the implementation note in [prune dead methods from the persistence seam](../../implemented/simplification/2026-06-20-prune-dead-seam-methods.md) records the test-migration cost dwarfing the surface removed). The per-task `BashTask.done` promise has no consumer through the public seam either (`dsh-tool-bash` completes via `onTaskDone`), but it is production-load-bearing INSIDE the implementation: `dsh-bash-local`'s disposal awaits it to reach quiescence. The seam therefore exposes two public completion representations — the per-task promise and the global `onTaskDone` listener registry — and the shipped consumers use only the latter: the runtime should pick exactly one public completion surface and record which. Two shape facts for the split to dissolve or preserve deliberately: `BashExecSpec.timeoutMs` is required but ignored by `start()` (documented in the seam JSDoc itself), and `stdin`/`env` ride the shared spec for the foreground trusted-plugin path — the carve-up must keep a plain in-process foreground `resolve`+`run` path carrying them, so hook execution is never forced through the long-running runtime. Adjacent blast radius: the credential scrub is duplicated between the two production spawn sites (`packages/bash/bash-local/src/run.ts` and `packages/subagent/subagent-acp/src/run.ts`); if the runtime absorbs spawn-env policy, collapsing that duplication is its work too.
-
-## Acceptance criteria
-
-- The bash-specific packages no longer define the generic task registry, owner-token authorization, polling, cancellation, or completion-notification machinery.
-- A shared long-running-task service or tool layer owns those semantics and is documented as the path for any future background-capable tool.
-- Bash background behavior remains available through the shared layer, with tests proving cross-session isolation still holds.
-- ACP and snapshot fixtures render background bash through the shared task vocabulary, not through bash-only lifecycle semantics.
-- The [tool cookbook](../../../cookbook/adding-a-tool.md) points long-running tools at the shared runtime instead of telling each tool to invent its own task protocol.
-
-## Risks
-
-The bash package loses local ownership of an already-working background-task implementation, and the implementing PR may temporarily churn model-facing tool names or transcript presentation. That churn is worthwhile if it leaves one background-task contract instead of making every future long-running tool clone bash's private protocol.
-
-
diff --git a/docs/rfc/proposed/feature/2026-07-08-background-subagent-tasks.md b/docs/rfc/proposed/feature/2026-07-08-background-subagent-tasks.md
deleted file mode 100644
index 97e8ce1ddb..0000000000
--- a/docs/rfc/proposed/feature/2026-07-08-background-subagent-tasks.md
+++ /dev/null
@@ -1,98 +0,0 @@
-# RFC: Background subagent tasks
-
-Status: proposed
-
-## Problem
-
-The subagent seam ([the seam RFC](../../implemented/feature/2026-06-21-subagent-capability-seam.md)) exposes `start() -> SubagentRun`, and the model-facing `dsh-tool-subagent` consumer collects that run synchronously: the parent turn blocks until the child returns one final result. That shape is simple and transport-neutral, but it makes slow delegation expensive for the parent. A model that wants two independent investigations must either run them serially or hold the parent step open for the entire child duration.
-
-The harness already has one background-task precedent in bash. Bash has task ids, owner-token checks, output polling, stop, completion notifications, and prompt guidance. Subagents need the same user-facing habit, but not by copying bash's process-output semantics: a subagent does not expose an incremental stdout stream, and its child session remains the home for internal steps. The parent needs to start a child, keep working, later wait for or read the final answer, and stop the task when it is no longer relevant.
-
-The design also has two lifecycle constraints that the synchronous cut does not face. First, a background subagent can outlive the tool call that started it, so the tool-call abort signal must not stay wired to the child after the id is returned. Second, a completion notice can only be injected into a live owner agent: once an ACP session closes or an agent handle is disposed, `agent.inject()` cannot append to that session. The feature must therefore define whether background subagents survive owner disposal.
-
-## Proposal
-
-Add a background mode to the existing model-facing subagent tools and add three companion tools: `subagent_wait`, `subagent_output`, and `subagent_stop`. The background task registry lives in `@deepseek-ai/dsh-subagent`, keyed by branded task ids and owner tokens, while `@deepseek-ai/dsh-tool-subagent` owns the model-facing schemas, text rendering, completion notice injection, and prompt guidance.
-
-`dsh-tool-subagent` becomes a single multi-tool consumer plugin instead of one plugin instance per provider. Its config maps model-facing tool names to provider names, so one plugin instance can register `subagent`, `subagent_fork`, and any deployment-specific aliases such as `subagent_acp`, plus the shared background control tools. Providers remain named implementations on `ctx.subagents`: `spawn`, `fork`, `acp`, or future backends. This keeps provider implementation and model-facing exposure separate while avoiding a failure mode where one `subagent` tool exposes `run_in_background` but the companion wait/output/stop tools were never loaded.
-
-The background task is scoped to the owner session, not durable across session closure. A background subagent starts only from a model-driven call with `exec.agent`; the service stores the caller's `session.header.id` as the owner token. `subagent_output`, `subagent_wait`, and `subagent_stop` compare that stored token with the caller's session id and reject cross-session access. When the owner agent is disposed, an awaited owner-cleanup path cancels any running background subagent tasks for that owner and waits for their settlement/dispose before the owner handle reports quiescence. Completion notices are best-effort: if the owner agent is still registered, `dsh-tool-subagent` injects a short `context/message`; if the owner is gone, no notice is written.
-
-## Tool surface
-
-Each configured delegation tool may expose `run_in_background?: boolean`. The deployment can disable background mode per tool; a disabled tool does not include the parameter in its schema. A foreground call keeps the synchronous semantics: it waits for `run.result`, returns final text on `completed`, maps non-clean terminal stop reasons to an errored tool result, and disposes the run in `finally`.
-
-A background call validates that a parent agent exists, starts the provider run through `ctx.subagents`, registers a task, and returns `started background subagent task `. It checks an already-aborted tool signal before starting, but after the id is returned it does not keep the tool-call signal connected to `run.cancel()`. The parent step may finish while the child continues.
-
-`subagent_output` is a non-blocking status read for a background subagent task. While the task is `running` or `stopping`, it returns only a status line. Once terminal, it returns the final text output or error message plus the terminal status. Reading output is idempotent and does not consume the result; v1 deliberately exposes no incremental transcript cursor because the child session remains the detailed trace.
-
-`subagent_wait` waits for a task to become terminal, bounded by a defaulted and capped timeout from `dsh-tool-subagent` config. A wait timeout returns `running` and leaves the child alive. Aborting the wait call cancels only the wait, not the background task.
-
-`subagent_stop` requests cancellation of a running or stopping task and returns immediately. The task registry remains responsible for observing the run settle, recording the terminal state, and disposing the run. Calling stop on an already-terminal task reports that terminal state rather than failing.
-
-## Runtime task model
-
-`@deepseek-ai/dsh-subagent` adds a runtime-global task registry to `SubagentService`. Task ids and owner tokens are branded types. A task snapshot records the task id, provider name, child run id, owner token, status, started/finished timestamps, final output, and error message. The status vocabulary is `running`, `stopping`, and the existing terminal `SubagentStopReason` values (`completed`, `aborted`, `error`, `max-tokens`, `refusal`, plus merge-extensible provider values).
-
-The registry owns task settlement. It attaches one continuation to `run.result`; on success it stores the final output and stop reason, on rejection it stores `error`, and in both cases it disposes the run and notifies task-done listeners. Listener failures are contained and logged so one consumer cannot starve cleanup.
-
-The registry is runtime-global because `ctx.subagents` is a service shared by all live agents in the Cordis context. Session isolation is therefore explicit owner-token authorization, not an assumption about separate service instances. This mirrors the bash background-task fence: predictable ids are safe only when read/stop operations check the caller's owner token.
-
-Owner disposal is a hard lifecycle boundary, but `agent/disposed` alone is not the cleanup mechanism. The current agent registry emits `agent/disposed` synchronously after removing the agent, and `AgentHandle.dispose()` does not await asynchronous listener work. This feature therefore also adds an awaited owner-cleanup seam: background task registration attaches an owner-scoped disposer that runs in the owning agent's disposal chain before that handle resolves. That disposer finds tasks owned by the agent's session id, requests cancellation, waits for each task's settlement path to record the terminal snapshot, and awaits `run.dispose()`. The existing `agent/disposed` event may still be used as a best-effort notification/fallback, but it must not be the path that promises child quiescence. The service does not attempt to persist unfinished task state, resume children, or inject into disposed sessions. A future durable job system can extend this boundary, but this feature intentionally keeps background subagents tied to live sessions.
-
-## Model guidance
-
-`dsh-tool-subagent` registers a system-prompt section that teaches the background-task habit:
-
-- Keep track of every task id returned by a background subagent call.
-- Do not produce a final answer while a relevant background subagent is still running.
-- While waiting, continue independent exploration or use other tools when useful.
-- Before summarizing or handing work back, call `subagent_wait` or `subagent_output` to collect finished tasks.
-- Call `subagent_stop` for a background task that is no longer needed.
-- End without collecting a task only when its result is irrelevant or the task was explicitly stopped.
-
-This prompt guidance is not the enforcement boundary. Runtime enforcement is owner-token authorization and the awaited owner-cleanup path. The guidance keeps ordinary model behavior from accidentally abandoning relevant work while still allowing explicit stop or irrelevance.
-
-## Relationship to generic long-running tools
-
-The generic long-running tool runtime RFC ([Extract a generic long-running tool runtime](../../proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md)) remains the larger direction for shared task ids, owner tokens, cancellation, completion notices, and presentation. Background subagents should not block on that extraction because subagents have a narrower result model than bash: no incremental stdout, no spill files, and no process exit markers. The implementation should keep the subagent registry small and shaped so it can later migrate into a generic runtime without changing the model-facing `run_in_background`, `subagent_wait`, `subagent_output`, and `subagent_stop` contract.
-
-## Alternatives considered
-
-### Why not keep one `dsh-tool-subagent` instance per provider?
-
-The existing one-instance-per-provider shape makes aliasing simple, but companion tools become ambiguous. If each instance registers `subagent_wait`, duplicate tool names collide. If only one instance registers them, deployments can accidentally expose `run_in_background` without the tools required to collect or stop the task. A single multi-tool consumer config keeps provider selection in deployment config and makes the background control plane atomic.
-
-### Why not put wait/output/stop in a separate plugin?
-
-A separate plugin has the same half-loaded failure mode: `subagent` could advertise background mode while the control tools are absent. The control tools are part of the model-facing subagent contract, so they should be registered by the same consumer plugin that adds `run_in_background`.
-
-### Why not let background subagents survive owner session closure?
-
-Survival after owner closure requires durable task state, child-session recovery, a way to surface late results into a reopened session, and policy for tasks whose owning client never returns. The current agent runtime unregisters disposed agents, and `agent.inject()` intentionally rejects disposed targets. Tying background tasks to an awaited owner-cleanup path makes the v1 lifecycle explicit and avoids orphaned child agents.
-
-### Why not skip owner-token checks because ACP sessions are isolated?
-
-ACP sessions isolate their logs and agents, but services such as `ctx.agents`, `ctx.tools`, and `ctx.subagents` are shared within the runtime. A background-task id is a global resource handle. Without an owner check, another live session in the same runtime could guess or receive a task id and read or stop it.
-
-### Why not expose incremental subagent transcript output?
-
-The child session is already the trace for internal reasoning, tool calls, and intermediate messages. Streaming that transcript into the parent would blur the parent/child log boundary that makes in-process and ACP providers equivalent. The first background surface returns status and final output only; richer observation belongs to UI/session tooling or a separate observation RFC.
-
-## Acceptance criteria
-
-- A deployment config can expose `subagent` and `subagent_fork` from one `dsh-tool-subagent` instance while binding them to different providers.
-- A configured delegation tool exposes `run_in_background` only when that tool enables background mode.
-- A background call returns a task id immediately and the parent can continue using other tools before collecting the result.
-- `subagent_output`, `subagent_wait`, and `subagent_stop` enforce owner-token access and reject cross-session task ids.
-- A task that finishes while the owner agent is live injects a durable completion notice into the owner session; a task whose owner is disposed does not throw while trying to notify.
-- Disposing the owner agent runs an awaited owner-cleanup path that cancels all of that owner's running background subagent tasks and reaches quiescence without leaking child agents; tests prove `agent/disposed` alone is not relied on for this guarantee.
-- Snapshot coverage proves the changed tool schemas and the completion-notice path; unit coverage pins foreground compatibility, background settlement, timeout, stop, owner isolation, and owner-disposal cleanup.
-
-## Risks
-
-The multi-tool config reshapes how deployments expose provider aliases, so examples and generated tool catalogs must move together with the implementation. The pre-release policy allows this churn, but the migration must update every shipped config in one change.
-
-The prompt guidance can reduce abandoned tasks but cannot force a model to collect every background result. Runtime cleanup through the awaited owner-disposal path is the hard stop; a future planner or guard could enforce "no final answer with relevant running tasks" more strongly if the prompt proves insufficient.
-
-The task registry duplicates some concepts named by the generic long-running-tool RFC. Keeping the subagent registry final-output-only and service-local limits that duplication, but a later generic runtime extraction will still need a careful migration.
diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-bash-output-spill-files.md b/docs/rfc/rejected/simplification/2026-06-20-drop-bash-output-spill-files.md
index 0a13d90f1b..859bd6f23d 100644
--- a/docs/rfc/rejected/simplification/2026-06-20-drop-bash-output-spill-files.md
+++ b/docs/rfc/rejected/simplification/2026-06-20-drop-bash-output-spill-files.md
@@ -12,7 +12,7 @@ This solves a real problem, but in a narrow and leaky way. A spill path is a pro
Keep tail truncation, drop full-output spill files. A bash result contains the bounded tail plus a clear truncation marker; no path is emitted. If users need full-output recovery, add a generic artifact/blob service with explicit ownership, cleanup, and UI rendering, then let bash attach large outputs to that service.
-This proposal can land independently of [a generic long-running tool runtime](../../proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md). If background tasks stay, `bash_output` should still report that output was dropped, but without advertising a spill path.
+This proposal can land independently of [a generic long-running tool runtime](../../implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md). If background tasks stay, `bash_output` should still report that output was dropped, but without advertising a spill path.
## Acceptance criteria
diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md
index a621d3299d..477df191db 100644
--- a/docs/tool-catalog.md
+++ b/docs/tool-catalog.md
@@ -15,9 +15,10 @@ This table connects model-visible tool names to the plugin package and service s
| Tool package | Model-visible names | Requires | Writes / affects | Shipped aliases | Deployment note |
| --- | --- | --- | --- | --- | --- |
-| `@deepseek-ai/dsh-tool-bash` | `bash`, `bash_kill`, `bash_output` | `ctx.tools`, `ctx.bash` | `tool/call`, `tool/result`, `context/message via agent.inject() for background completion notices` | - | The bash/bash_output/bash_kill tools are model-facing consumers of the bash executor seam. |
+| `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.bash`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. |
| `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. |
| `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/coding-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. |
+| `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `context/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: a background bash command and a background subagent are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.register()`. |
| `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist or ACP plan. |
| `@deepseek-ai/dsh-tool-web` | `web_fetch`, `web_search` | `ctx.tools`, `ctx.web`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | web_search and web_fetch keep provider selection behind ctx.web so model-visible schemas stay stable across backend swaps. |
@@ -25,7 +26,7 @@ This table connects model-visible tool names to the plugin package and service s
### `bash`
-Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`.
+Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.
```json
{
@@ -49,7 +50,7 @@ Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs
},
"run_in_background": {
"type": "boolean",
- "description": "Run in the background and return a task id immediately. No timeout applies."
+ "description": "Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."
}
},
"required": [
@@ -61,49 +62,7 @@ Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs
Source: [`packages/bash/tool-bash/src/index.ts`](../packages/bash/tool-bash/src/index.ts)
-### `bash_kill`
-
-Ask the executor to kill a running background bash task by task id.
-
-```json
-{
- "type": "object",
- "properties": {
- "task_id": {
- "type": "string",
- "description": "Task id returned by the bash tool."
- }
- },
- "required": [
- "task_id"
- ]
-}
-```
-
-Source: [`packages/bash/tool-bash/src/index.ts`](../packages/bash/tool-bash/src/index.ts)
-
-### `bash_output`
-
-Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.
-
-```json
-{
- "type": "object",
- "properties": {
- "task_id": {
- "type": "string",
- "description": "Task id returned by the bash tool."
- }
- },
- "required": [
- "task_id"
- ]
-}
-```
-
-Source: [`packages/bash/tool-bash/src/index.ts`](../packages/bash/tool-bash/src/index.ts)
-
-The bash/bash_output/bash_kill tools are model-facing consumers of the bash executor seam.
+The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled.
## `@deepseek-ai/dsh-tool-fs`
@@ -203,7 +162,7 @@ The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `
### `subagent`
-Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.
+Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to get a task id immediately and keep working; collect the final answer with `task_output` (wait: true when you are blocked on it) and stop it with `task_kill`.
```json
{
@@ -216,6 +175,10 @@ Delegate a self-contained task to a subagent (a separate agent that works in its
"prompt": {
"type": "string",
"description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."
+ },
+ "run_in_background": {
+ "type": "boolean",
+ "description": "Run the subagent as a background task and return a task id immediately (collect with task_output, stop with task_kill)."
}
},
"required": [
@@ -229,6 +192,77 @@ Source: [`packages/subagent/tool-subagent/src/index.ts`](../packages/subagent/to
The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/coding-agent/cordis.yml` and `examples/acp-agent/cordis.yml`.
+## `@deepseek-ai/dsh-tool-tasks`
+
+### `task_kill`
+
+Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.
+
+```json
+{
+ "type": "object",
+ "properties": {
+ "task_id": {
+ "type": "string",
+ "description": "Task id returned by the tool that started the background work."
+ },
+ "reason": {
+ "type": "string",
+ "description": "Optional short reason, recorded in the log and forwarded to the task."
+ }
+ },
+ "required": [
+ "task_id"
+ ]
+}
+```
+
+Source: [`packages/tasks/tool-tasks/src/index.ts`](../packages/tasks/tool-tasks/src/index.ts)
+
+### `task_list`
+
+List your background tasks (running and finished) with their ids, kinds, and statuses.
+
+```json
+{
+ "type": "object",
+ "properties": {}
+}
+```
+
+Source: [`packages/tasks/tool-tasks/src/index.ts`](../packages/tasks/tool-tasks/src/index.ts)
+
+### `task_output`
+
+Read output/status from a background task (started by a tool with `run_in_background`). Stream tasks (bash) return only output produced since your previous task_output call; final-output tasks (subagent) return the final answer once the task finishes. Every response ends with a [status: ...] line. Non-blocking by default; set `wait: true` to block until the task finishes (bounded by a capped timeout) when you are genuinely blocked on its result.
+
+```json
+{
+ "type": "object",
+ "properties": {
+ "task_id": {
+ "type": "string",
+ "description": "Task id returned by the tool that started the background work."
+ },
+ "wait": {
+ "type": "boolean",
+ "description": "Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."
+ },
+ "timeout_ms": {
+ "type": "number",
+ "description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."
+ }
+ },
+ "required": [
+ "task_id"
+ ]
+}
+```
+
+Source: [`packages/tasks/tool-tasks/src/index.ts`](../packages/tasks/tool-tasks/src/index.ts)
+
+The kind-agnostic background-task control surface: a background bash command and a background subagent are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.register()`.
+
## `@deepseek-ai/dsh-tool-todo`
### `todo_write`
diff --git a/examples/acp-agent/tests/snapshots/text-turn/session.jsonl b/examples/acp-agent/tests/snapshots/text-turn/session.jsonl
index 2a71c46b18..efb5bd317a 100644
--- a/examples/acp-agent/tests/snapshots/text-turn/session.jsonl
+++ b/examples/acp-agent/tests/snapshots/text-turn/session.jsonl
@@ -1,36 +1,36 @@
-{"type":"session","version":0,"id":"a407f6bc-310c-4c0e-ad8b-4ffdf1b544b1","createdAt":1783279329590,"cwd":"/tmp/acp-snap-cwd-q0sbE9"}
-{"type":"turn/start","seq":0,"time":1783279329596,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
-{"type":"user/message","seq":1,"time":1783279329596,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"}
-{"type":"step/start","seq":2,"time":1783279329598,"data":{"turn":1,"step":1}}
-{"type":"request/header","seq":3,"time":1783279329598,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are a coding assistant powered by the deepseek-v4-flash model. Your working\ndirectory is /tmp/acp-snap-cwd-q0sbE9.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}}
-{"type":"assistant/chunk","seq":4,"time":1783279330062,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
-{"type":"assistant/chunk","seq":5,"time":1783279330062,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}}
-{"type":"assistant/chunk","seq":6,"time":1783279330154,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}}
-{"type":"assistant/chunk","seq":7,"time":1783279330183,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}}
-{"type":"assistant/chunk","seq":8,"time":1783279330183,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}}
-{"type":"assistant/chunk","seq":9,"time":1783279330183,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}}
-{"type":"assistant/chunk","seq":10,"time":1783279330184,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}}
-{"type":"assistant/chunk","seq":11,"time":1783279330184,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}
-{"type":"assistant/chunk","seq":12,"time":1783279330184,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}}
-{"type":"assistant/chunk","seq":13,"time":1783279330210,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}}
-{"type":"assistant/chunk","seq":14,"time":1783279330211,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}}
-{"type":"assistant/chunk","seq":15,"time":1783279330211,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}
-{"type":"assistant/chunk","seq":16,"time":1783279330211,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}}
-{"type":"assistant/chunk","seq":17,"time":1783279330211,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONG"}}}
-{"type":"assistant/chunk","seq":18,"time":1783279330211,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}}
-{"type":"assistant/chunk","seq":19,"time":1783279330238,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}}
-{"type":"assistant/chunk","seq":20,"time":1783279330238,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" not"}}}
-{"type":"assistant/chunk","seq":21,"time":1783279330239,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}}
-{"type":"assistant/chunk","seq":22,"time":1783279330239,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" any"}}}
-{"type":"assistant/chunk","seq":23,"time":1783279330239,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tools"}}}
-{"type":"assistant/chunk","seq":24,"time":1783279330239,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}
-{"type":"assistant/chunk","seq":25,"time":1783279330268,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
-{"type":"assistant/chunk","seq":26,"time":1783279330268,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"P"}}}
-{"type":"assistant/chunk","seq":27,"time":1783279330268,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ONG"}}}
-{"type":"assistant/chunk","seq":28,"time":1783279330269,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."}}}}
-{"type":"assistant/chunk","seq":29,"time":1783279330269,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PONG"}}}}
-{"type":"assistant/chunk","seq":30,"time":1783279330269,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2095,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}}}}
-{"type":"assistant/chunk","seq":31,"time":1783279330269,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
-{"type":"assistant/message","seq":32,"time":1783279330271,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."},{"type":"text","text":"PONG"}],"usage":{"inputTokens":2095,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31],"surfaceOp":"append"}
-{"type":"step/end","seq":33,"time":1783279330271,"data":{"turn":1,"step":1}}
-{"type":"turn/end","seq":34,"time":1783279330271,"data":{"turn":1,"reason":{"kind":"completed"}}}
+{"type":"session","version":0,"id":"10267d34-6c0c-4e0c-8a2d-57644c57d0d5","createdAt":1783597843469,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-YUSqHr"}
+{"type":"turn/start","seq":0,"time":1783597843471,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
+{"type":"user/message","seq":1,"time":1783597843471,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"}
+{"type":"step/start","seq":2,"time":1783597843472,"data":{"turn":1,"step":1}}
+{"type":"request/header","seq":3,"time":1783597843472,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are a coding assistant powered by the deepseek-v4-flash model. Your working\ndirectory is /var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-YUSqHr.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to get a task id immediately and keep working; collect the final answer with `task_output` (wait: true when you are blocked on it) and stop it with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run the subagent as a background task and return a task id immediately (collect with task_output, stop with task_kill)."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to get a task id immediately and keep working; collect the final answer with `task_output` (wait: true when you are blocked on it) and stop it with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run the subagent as a background task and return a task id immediately (collect with task_output, stop with task_kill)."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read output/status from a background task (started by a tool with `run_in_background`). Stream tasks (bash) return only output produced since your previous task_output call; final-output tasks (subagent) return the final answer once the task finishes. Every response ends with a [status: ...] line. Non-blocking by default; set `wait: true` to block until the task finishes (bounded by a capped timeout) when you are genuinely blocked on its result.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}}
+{"type":"assistant/chunk","seq":4,"time":1783597843472,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
+{"type":"assistant/chunk","seq":5,"time":1783597843472,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}}
+{"type":"assistant/chunk","seq":6,"time":1783597843472,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}}
+{"type":"assistant/chunk","seq":7,"time":1783597843472,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}}
+{"type":"assistant/chunk","seq":8,"time":1783597843472,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}}
+{"type":"assistant/chunk","seq":9,"time":1783597843472,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}}
+{"type":"assistant/chunk","seq":10,"time":1783597843472,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}}
+{"type":"assistant/chunk","seq":11,"time":1783597843472,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}
+{"type":"assistant/chunk","seq":12,"time":1783597843472,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}}
+{"type":"assistant/chunk","seq":13,"time":1783597843473,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}}
+{"type":"assistant/chunk","seq":14,"time":1783597843473,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}}
+{"type":"assistant/chunk","seq":15,"time":1783597843473,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}
+{"type":"assistant/chunk","seq":16,"time":1783597843473,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}}
+{"type":"assistant/chunk","seq":17,"time":1783597843473,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONG"}}}
+{"type":"assistant/chunk","seq":18,"time":1783597843473,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}}
+{"type":"assistant/chunk","seq":19,"time":1783597843473,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}}
+{"type":"assistant/chunk","seq":20,"time":1783597843473,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" not"}}}
+{"type":"assistant/chunk","seq":21,"time":1783597843473,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}}
+{"type":"assistant/chunk","seq":22,"time":1783597843473,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" any"}}}
+{"type":"assistant/chunk","seq":23,"time":1783597843473,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tools"}}}
+{"type":"assistant/chunk","seq":24,"time":1783597843473,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}
+{"type":"assistant/chunk","seq":25,"time":1783597843473,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
+{"type":"assistant/chunk","seq":26,"time":1783597843473,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"P"}}}
+{"type":"assistant/chunk","seq":27,"time":1783597843473,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ONG"}}}
+{"type":"assistant/chunk","seq":28,"time":1783597843473,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."}}}}
+{"type":"assistant/chunk","seq":29,"time":1783597843473,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PONG"}}}}
+{"type":"assistant/chunk","seq":30,"time":1783597843473,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2095,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}}}}
+{"type":"assistant/chunk","seq":31,"time":1783597843473,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
+{"type":"assistant/message","seq":32,"time":1783597843473,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."},{"type":"text","text":"PONG"}],"usage":{"inputTokens":2095,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31],"surfaceOp":"append"}
+{"type":"step/end","seq":33,"time":1783597843473,"data":{"turn":1,"step":1}}
+{"type":"turn/end","seq":34,"time":1783597843473,"data":{"turn":1,"reason":{"kind":"completed"}}}
diff --git a/examples/coding-agent/README.md b/examples/coding-agent/README.md
index 4b15d2dc5a..6b2467a608 100644
--- a/examples/coding-agent/README.md
+++ b/examples/coding-agent/README.md
@@ -11,7 +11,7 @@ The REPL agent demo wiring: DeepSeek V4 + the `read`/`write`/`edit` filesystem t
pnpm run demo:repl
```
-Type a coding task. The agent works through the `read`/`write`/`edit` filesystem tools for ordinary file operations and `bash` (+ `bash_output` / `bash_kill` for background tasks) for shell commands, searches, and test runs, each in a fresh `bash -c` (the system prompt tells the model to pass `workdir` instead of `cd`). Both the fs tools and bash resolve relative paths against the session workspace. It can also delegate with `subagent`/`subagent_fork` and track multi-step work with `todo_write` (a whole-list task tracker rendered as a checklist). Reasoning streams dimmed; tool calls/results render inline.
+Type a coding task. The agent works through the `read`/`write`/`edit` filesystem tools for ordinary file operations and `bash` (+ the generic `task_output` / `task_list` / `task_kill` for background tasks) for shell commands, searches, and test runs, each in a fresh `bash -c` (the system prompt tells the model to pass `workdir` instead of `cd`). Both the fs tools and bash resolve relative paths against the session workspace. It can also delegate with `subagent`/`subagent_fork` and track multi-step work with `todo_write` (a whole-list task tracker rendered as a checklist). Reasoning streams dimmed; tool calls/results render inline.
```
> fix the failing test in /path/to/project
@@ -39,7 +39,7 @@ This example is a thin leaf `cordis.yml`: it picks the swappable backends, loads
|---|---|
| `hmr` (`@cordisjs/plugin-hmr`) | the dev/demo edit-reload loop — a **leaf** entry (not baked into the app) because it is Loader-only and needs `node --expose-internals`, which `demo:repl` passes |
| `llm-deepseek` | real `LlmAdapter` via config (`!!js process.env.…` secrets); swap one line to `@deepseek-ai/dsh-llm-pi-ai` for the library-backed twin |
-| `bash` (`dsh-bash-local`) | the executor implementation — the swappable half of the bash seam. The model-facing `bash`/`bash_output`/`bash_kill` tool schemas (`tool-bash`) come from `agent-core`, so only the executor is a leaf choice |
+| `bash` (`dsh-bash-local`) | the executor implementation — the swappable half of the bash seam. The model-facing `bash` schema (`tool-bash`) and the `task_*` control tools (`tool-tasks`) come from `agent-core`, so only the executor is a leaf choice |
| `stdio-agent` (`@deepseek-ai/dsh-stdio-agent`) | the app bundle: the agent-core spine + console logger + JSONL persistence + readline UI + a pre-created `main` agent. Its config carries the model, system prompt, `persistenceRoot` (`./.sessions`), and `resumeSessionId` — so persistence and the agent are configured here, not wired as separate leaf plugins |
| `subagent`, `subagent-spawn`, `subagent-fork` | the subagent provider registry plus the two in-process backends: a fresh child and a child seeded with the parent's completed-turn prefix |
| `tool-subagent`, `tool-subagent-fork` | two model-facing `dsh-tool-subagent` loads, each bound to a different provider and exposed under a distinct tool name (`subagent`, `subagent_fork`) |
diff --git a/examples/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml
index 0439262e33..a0d17b425b 100644
--- a/examples/coding-agent/cordis.yml
+++ b/examples/coding-agent/cordis.yml
@@ -2,7 +2,8 @@
# backends — the DeepSeek adapter and the local bash executor — plus `hmr` for
# the dev/demo reload loop, then the stdio chat app (@deepseek-ai/dsh-stdio-
# agent), which bundles the whole agent-core spine (timer, llm, sessions,
-# system-prompt, tools, agents, invariants, tool-bash, agent-loop), the console
+# system-prompt, tools, agents, tasks + the task_* control tools, invariants,
+# tool-bash, agent-loop), the console
# logger, JSONL persistence, the readline UI, and a pre-created `main` agent.
#
# `hmr` is a leaf entry (not baked into dsh-stdio-agent): it is a Loader-only
diff --git a/packages/README.md b/packages/README.md
index da75f740e8..91691eaceb 100644
--- a/packages/README.md
+++ b/packages/README.md
@@ -15,6 +15,7 @@ Packages are grouped by modular role at `packages///`. The group dir
| [`fs/`](fs/README.md) | Filesystem capability family: the abstract seam, a local impl, and the model-facing file tools | Product — stable surface |
| [`compact/`](compact/README.md) | Compaction capability family: the abstract seam + a basic backend (tool deferred) | Product — stable surface |
| [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface |
+| [`tasks/`](tasks/README.md) | Background task family: the `ctx.tasks` registry + the generic `task_*` control tools | Product — stable surface |
| [`web/`](web/README.md) | Web capability family: the abstract seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface |
| [`todo/`](todo/README.md) | Todo/planning family: the model-facing `todo_write` tool (whole-list task tracking on the session log) | Product — stable surface |
| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
diff --git a/packages/bash/README.md b/packages/bash/README.md
index 9a9dba88d5..d68aa1c700 100644
--- a/packages/bash/README.md
+++ b/packages/bash/README.md
@@ -6,6 +6,6 @@ The canonical three-package capability seam (see [capability seams](../../docs/r
|---|---|---|
| `bash/` | Abstract bash executor seam (interface + vocabulary) | `ctx.bash` |
| `bash-local/` | Local-subprocess `BashExecutor` implementation | (registers `ctx.bash`) |
-| `tool-bash/` | Model-facing `bash`/`bash_output`/`bash_kill` tool schemas | (registers on `ctx.tools`) |
+| `tool-bash/` | Model-facing `bash` tool schema | (registers on `ctx.tools`) |
-The interface lives at `bash/bash/`. A sandboxed executor would replace `bash-local` without touching the interface or the tool — the split is what makes that possible.
+The interface lives at `bash/bash/`. A sandboxed executor would replace `bash-local` without touching the interface or the tool — the split is what makes that possible. Background runs are generic tasks, not bash-private state: `tool-bash` registers a started `BashProcess` handle with the [`ctx.tasks` registry](../tasks/README.md), whose `task_*` tools collect and stop it.
diff --git a/packages/bash/bash-local/README.md b/packages/bash/bash-local/README.md
index dec29ce93b..b97dba3f25 100644
--- a/packages/bash/bash-local/README.md
+++ b/packages/bash/bash-local/README.md
@@ -23,7 +23,7 @@ Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi;
- **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). ESRCH is tolerated; daemons that re-parent away from the group can still survive — same caveat as the surveyed tools.
- **Tail-keep truncation + 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. If the final spill close reports a delayed writeback failure, the executor still returns the tail but withholds the path rather than advertising a possibly incomplete file.
- **Model-friendly env + credential scrub** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`), then `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results. This scrub is the security control that keeps the harness's *ambient* credentials out of a spawned command. A spec's `env` is merged LAST (after the scrub), so a caller's explicit entry — a value it already holds — wins even on a credential-shaped name. The spec's `stdin`, when supplied, is written to the child and closed; with none supplied, fd 0 is `/dev/null` — the exact pre-seam default, so a command that probes stdin's file type is unaffected. Both `env`/`stdin` are set by in-process plugins (the hooks bridges); the model-facing tool doesn't expose them. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
-- **Background tasks** — `start()` returns immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), `readOutput()` is incremental with whole-stream byte offsets, and disposal kills everything. The spec's opaque `owner` token is stored on the tracked task and returned by `ownerOf(id)` — the executor never interprets it (the consumer's access policy does), and because it lives with the task here it survives a `tool-bash` HMR reload.
+- **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.
## Sandboxing
diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts
index cdac3985b8..a4707e1c83 100644
--- a/packages/bash/bash-local/src/index.ts
+++ b/packages/bash/bash-local/src/index.ts
@@ -2,7 +2,8 @@
* `LocalBashExecutor`: the local-subprocess implementation of the
* `@deepseek-ai/dsh-bash` executor seam. Spawns `bash -c` per call in its
* own process group (see `./run.ts` for the plumbing and the agent-tool
- * survey notes), tracks background tasks, and kills everything on dispose.
+ * survey notes), tracks live background processes for disposal quiescence
+ * ONLY (task semantics live in `ctx.tasks`), and kills everything on dispose.
*
* TODO(permissions/sandbox): execution policy does NOT belong here — use
* the `tools/pre-execute` deny/ask gate (see docs/architecture.md
@@ -16,8 +17,8 @@
import { Context } from 'cordis'
import z from 'schemastery'
-import { BashExecutor, BashTaskId } from '@deepseek-ai/dsh-bash'
-import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead, OwnerToken } from '@deepseek-ai/dsh-bash'
+import { BashExecutor } from '@deepseek-ai/dsh-bash'
+import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult } from '@deepseek-ai/dsh-bash'
import { DEFAULT_GRACE_MS, runBash } from './run.ts'
import type { RunInternals, RunningBash } from './run.ts'
@@ -47,15 +48,6 @@ function assertPositiveFinite(name: string, value: number): void {
}
}
-interface TrackedTask extends BashTask {
- running: RunningBash
- /** Whole-stream byte offsets already delivered via {@link LocalBashExecutor.readOutput}. */
- stdoutOffset: number
- stderrOffset: number
- /** Opaque owner token from the {@link BashExecSpec} (the consumer's isolation key). */
- owner: OwnerToken | undefined
-}
-
/**
* Local-subprocess bash executor. Defaults follow the agent-tool survey
* consensus: 120s default / 600s max timeout (Claude Code, OpenCode), 64KB
@@ -71,8 +63,12 @@ export class LocalBashExecutor extends BashExecutor {
graceMs: z.number().default(DEFAULT_GRACE_MS),
})
- private tasks = new Map()
- private nextTaskId = 1
+ /**
+ * Live background processes, tracked for DISPOSAL only: an entry leaves
+ * the map the moment its process settles (callers keep reading through
+ * their own {@link BashProcess} handle — the buffers live on it).
+ */
+ private live = new Map()
/** Test seam: spill knobs forwarded to runBash. */
internals: RunInternals = {}
@@ -91,17 +87,14 @@ export class LocalBashExecutor extends BashExecutor {
ctx.effect(() => async () => {
// Kill every live process group and WAIT for the processes to close so
// nothing outlives the fiber (HMR safety) — a TERM-trapping child is
- // held until the SIGKILL escalation lands. The base class already
- // silenced listeners, so these kills complete without notices.
+ // held until the SIGKILL escalation lands.
const pending: Promise[] = []
- for (const task of this.tasks.values()) {
- if (task.status === 'running') {
- task.status = 'killed'
- task.running.kill()
- pending.push(task.done)
- }
+ for (const [proc, running] of this.live) {
+ proc.status = 'killed'
+ running.kill()
+ pending.push(proc.done)
}
- this.tasks.clear()
+ this.live.clear()
await Promise.all(pending)
}, 'local bash teardown')
}
@@ -125,9 +118,6 @@ export class LocalBashExecutor extends BashExecutor {
// means none). env merges AFTER the scrub in run.ts.
...request.stdin !== undefined ? { stdin: request.stdin } : {},
...request.env !== undefined ? { env: request.env } : {},
- // Carry the owner through verbatim (required-but-nullable on the spec):
- // the executor never interprets it — the consumer's access policy does.
- owner: request.owner,
}
}
@@ -145,12 +135,12 @@ export class LocalBashExecutor extends BashExecutor {
return { ...outcome, timeoutMs: spec.timeoutMs }
}
- start(spec: BashExecSpec): BashTask {
- // No timeout for background tasks (matches Claude Code, which detaches
- // the timeout when backgrounding); callers stop tasks via kill() — or
- // via spec.signal, which the seam contract honors for background runs
- // too (runBash wires it to the group kill). spec.timeoutMs is ignored
- // here by design.
+ start(spec: BashExecSpec): BashProcess {
+ // No timeout for background processes (matches Claude Code, which
+ // detaches the timeout when backgrounding); callers stop them via the
+ // handle's kill() — or via spec.signal, which the seam contract honors
+ // for background runs too (runBash wires it to the group kill).
+ // spec.timeoutMs is ignored here by design.
const running = runBash({
command: spec.command,
cwd: spec.workdir,
@@ -162,79 +152,54 @@ export class LocalBashExecutor extends BashExecutor {
env: spec.env,
}, this.internals)
- const id = BashTaskId(`bash-${this.nextTaskId++}`)
- const task: TrackedTask = {
- id,
+ let stdoutOffset = 0
+ let stderrOffset = 0
+ const proc: BashProcess = {
command: spec.command,
status: 'running',
exitCode: null,
signal: null,
- owner: spec.owner,
- running,
- stdoutOffset: 0,
- stderrOffset: 0,
done: running.done.then((outcome) => {
- // Abort-killed tasks report as killed, not completed.
- if (task.status === 'running') task.status = outcome.aborted ? 'killed' : 'completed'
- task.exitCode = outcome.exitCode
- task.signal = outcome.signal
- this.notifyTaskDone(task)
+ // Abort-killed processes report as killed, not completed.
+ if (proc.status === 'running') proc.status = outcome.aborted ? 'killed' : 'completed'
+ proc.exitCode = outcome.exitCode
+ proc.signal = outcome.signal
+ this.live.delete(proc)
}, (error: unknown) => {
- // Spawn-level failure (bad workdir, …): the task never ran. String()
+ // Spawn-level failure (bad workdir, …): the process never ran. The
+ // error is surfaced through the read path, not a rejection. String()
// suffices — runBash only rejects with Error instances.
- task.status = 'killed'
- task.running.stderr.push(Buffer.from(`spawn failed: ${String(error)}`))
- this.notifyTaskDone(task)
+ proc.status = 'killed'
+ running.stderr.push(Buffer.from(`spawn failed: ${String(error)}`))
+ this.live.delete(proc)
}),
+ readOutput: (): BashProcessRead => {
+ const out = running.stdout.readFrom(stdoutOffset)
+ const err = running.stderr.readFrom(stderrOffset)
+ stdoutOffset = out.nextOffset
+ stderrOffset = err.nextOffset
+
+ // 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}` : '')
+ return {
+ delta,
+ lossy: out.lossy || err.lossy,
+ ...out.spillPath !== undefined ? { stdoutSpillPath: out.spillPath } : {},
+ ...err.spillPath !== undefined ? { stderrSpillPath: err.spillPath } : {},
+ }
+ },
+ kill: (): boolean => {
+ if (proc.status !== 'running') return false
+ proc.status = 'killed'
+ running.kill()
+ return true
+ },
}
- this.tasks.set(id, task)
- return task
- }
-
- get(id: BashTaskId): BashTask | undefined {
- return this.tasks.get(id)
- }
-
- ownerOf(id: BashTaskId): OwnerToken | undefined {
- // Unknown id and known-but-ownerless both read as undefined — the consumer
- // treats undefined as "open" and a truly unknown id fails at readOutput/kill.
- return this.tasks.get(id)?.owner
- }
-
- list(): BashTask[] {
- return [...this.tasks.values()]
- }
-
- readOutput(id: BashTaskId): BashTaskRead {
- const task = this.tasks.get(id)
- if (!task) throw new Error(`unknown bash task "${id}"`)
-
- const out = task.running.stdout.readFrom(task.stdoutOffset)
- const err = task.running.stderr.readFrom(task.stderrOffset)
- task.stdoutOffset = out.nextOffset
- task.stderrOffset = err.nextOffset
-
- // 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}` : '')
- return {
- task,
- delta,
- lossy: out.lossy || err.lossy,
- ...out.spillPath !== undefined ? { stdoutSpillPath: out.spillPath } : {},
- ...err.spillPath !== undefined ? { stderrSpillPath: err.spillPath } : {},
- }
- }
-
- kill(id: BashTaskId): boolean {
- const task = this.tasks.get(id)
- if (!task) throw new Error(`unknown bash task "${id}"`)
- if (task.status !== 'running') return false
- task.status = 'killed'
- task.running.kill()
- 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 ce89b2a0ae..c860b88706 100644
--- a/packages/bash/bash-local/tests/executor.spec.ts
+++ b/packages/bash/bash-local/tests/executor.spec.ts
@@ -1,11 +1,10 @@
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
-import { describe, expect, it, vi } from 'vitest'
+import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
-import { BashTaskId } from '@deepseek-ai/dsh-bash'
-import type { BashTaskRead } from '@deepseek-ai/dsh-bash'
+import type { BashProcess } from '@deepseek-ai/dsh-bash'
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-exec-spec-'))
@@ -18,34 +17,20 @@ async function setup(config: ConstructorParameters[1]
return { ctx, bash }
}
-/** Poll until a pid no longer exists. */
-async function waitGone(pid: number, timeoutMs = 5_000): Promise {
+/**
+ * Poll a handle's consuming readOutput until the ACCUMULATED delta contains
+ * `expected`; returns the accumulation (reads never re-deliver, so the caller
+ * gets everything produced up to the match).
+ */
+async function readUntil(proc: BashProcess, expected: string, timeoutMs = 5_000): Promise {
const deadline = Date.now() + timeoutMs
+ let all = ''
while (Date.now() < deadline) {
- try {
- process.kill(pid, 0)
- } catch {
- return
- }
+ all += proc.readOutput().delta
+ if (all.includes(expected)) return all
await new Promise(resolve => setTimeout(resolve, 20))
}
- throw new Error(`pid ${pid} still alive after ${timeoutMs}ms`)
-}
-
-async function readUntil(
- bash: LocalBashExecutor,
- id: BashTaskId,
- expected: string,
- timeoutMs = 5_000,
-): Promise {
- const deadline = Date.now() + timeoutMs
- let last: BashTaskRead | undefined
- while (Date.now() < deadline) {
- last = bash.readOutput(id)
- if (last.delta.includes(expected)) return last
- await new Promise(resolve => setTimeout(resolve, 20))
- }
- throw new Error(`task ${id} output did not include ${JSON.stringify(expected)}; last delta was ${JSON.stringify(last?.delta ?? '')}`)
+ throw new Error(`process output did not include ${JSON.stringify(expected)}; accumulated ${JSON.stringify(all)}`)
}
describe('LocalBashExecutor.run', () => {
@@ -88,15 +73,6 @@ describe('LocalBashExecutor.run', () => {
expect(() => bash.resolve({ command: 'true', timeoutMs: -1 })).toThrow(/request\.timeoutMs/)
})
- it('kill escalation uses the configured graceMs (a TERM-trapping task dies by SIGKILL)', async () => {
- const { bash } = await setup() // setup pins graceMs: 200 via config
- const task = bash.start(bash.resolve({ command: 'trap \'\' TERM; sleep 60' }))
- await new Promise(resolve => setTimeout(resolve, 100))
- bash.kill(task.id)
- await task.done
- expect(task.signal).toBe('SIGKILL')
- })
-
it('per-call timeout takes precedence under the cap and kills on expiry', async () => {
const { bash } = await setup({ timeoutMs: 60_000 })
const result = await bash.run(bash.resolve({ command: 'sleep 60', timeoutMs: 100 }))
@@ -136,229 +112,175 @@ describe('LocalBashExecutor.run', () => {
})
})
-describe('LocalBashExecutor background tasks', () => {
- it('start returns immediately with a registered running task', async () => {
+describe('LocalBashExecutor.start (background process handles)', () => {
+ it('start returns immediately with a running handle that settles as completed', async () => {
const { bash } = await setup()
const before = Date.now()
- const task = bash.start(bash.resolve({ command: 'sleep 0.2; echo done' }))
+ const proc = bash.start(bash.resolve({ command: 'sleep 0.2; echo done' }))
expect(Date.now() - before).toBeLessThan(150)
- expect(task.status).toBe('running')
- expect(bash.get(task.id)).toBe(task)
- expect(bash.list()).toContain(task)
- await task.done
- expect(task.status).toBe('completed')
- expect(task.exitCode).toBe(0)
+ expect(proc.command).toBe('sleep 0.2; echo done')
+ expect(proc.status).toBe('running')
+ await proc.done
+ expect(proc.status).toBe('completed')
+ expect(proc.exitCode).toBe(0)
})
- it('assigns sequential ids', async () => {
+ it('threads stdin and extra env into a background process', async () => {
const { bash } = await setup()
- const first = bash.start(bash.resolve({ command: 'true' }))
- const second = bash.start(bash.resolve({ command: 'true' }))
- expect(first.id).toBe('bash-1')
- expect(second.id).toBe('bash-2')
- await Promise.all([first.done, second.done])
- })
-
- it('threads stdin and extra env into a background task', async () => {
- const { bash } = await setup()
- const task = bash.start(bash.resolve({
+ const proc = bash.start(bash.resolve({
command: 'cat; echo "[$DSH_BG_VAR]"',
stdin: 'bg-stdin\n',
env: { DSH_BG_VAR: 'bg-env' },
}))
- const read = await readUntil(bash, task.id, '[bg-env]')
- expect(read.delta).toContain('bg-stdin')
- await task.done
- expect(task.exitCode).toBe(0)
+ const output = await readUntil(proc, '[bg-env]')
+ expect(output).toContain('bg-stdin')
+ await proc.done
+ expect(proc.exitCode).toBe(0)
})
- it('readOutput returns increments without re-delivery', async () => {
+ it('readOutput is consuming: increments are never re-delivered, and reads stay valid after exit', async () => {
const { bash } = await setup()
- const task = bash.start(bash.resolve({ command: 'echo first; sleep 1; echo second' }))
- const first = await readUntil(bash, task.id, 'first\n')
- expect(first.delta).toBe('first\n')
- expect(first.lossy).toBe(false)
- await task.done
- const second = bash.readOutput(task.id)
+ const proc = bash.start(bash.resolve({ command: 'echo first; sleep 1; echo second' }))
+ const first = await readUntil(proc, 'first\n')
+ expect(first).toBe('first\n')
+ await proc.done
+ // Read-after-exit returns the remaining buffered output — once.
+ const second = proc.readOutput()
expect(second.delta).toBe('second\n')
- const third = bash.readOutput(task.id)
- expect(third.delta).toBe('')
+ expect(second.lossy).toBe(false)
+ expect(proc.readOutput().delta).toBe('')
})
it('readOutput marks stderr sections', async () => {
const { bash } = await setup()
- const task = bash.start(bash.resolve({ command: 'echo out; echo err >&2' }))
- await task.done
- const read = bash.readOutput(task.id)
- expect(read.delta).toBe('out\n[stderr]\nerr\n')
+ const proc = bash.start(bash.resolve({ command: 'echo out; echo err >&2' }))
+ await proc.done
+ expect(proc.readOutput().delta).toBe('out\n[stderr]\nerr\n')
})
it('readOutput reports stderr-only deltas without a leading newline', async () => {
const { bash } = await setup()
- const task = bash.start(bash.resolve({ command: 'echo err >&2' }))
- await task.done
- expect(bash.readOutput(task.id).delta).toBe('[stderr]\nerr\n')
+ const proc = bash.start(bash.resolve({ command: 'echo err >&2' }))
+ await proc.done
+ expect(proc.readOutput().delta).toBe('[stderr]\nerr\n')
})
- it('readOutput flags lossy reads and reports spill paths', async () => {
+ it('readOutput adds a separator only when stdout lacks a trailing newline', async () => {
+ const { bash } = await setup()
+ const proc = bash.start(bash.resolve({ command: 'printf out; echo err >&2' }))
+ await proc.done
+ expect(proc.readOutput().delta).toBe('out\n[stderr]\nerr\n')
+ })
+
+ it('readOutput flags lossy reads and reports stdout spill paths', async () => {
const { bash } = await setup({ maxOutputBytes: 100 })
- const task = bash.start(bash.resolve({ command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i; done' }))
- await task.done
- const read = bash.readOutput(task.id)
+ const proc = bash.start(bash.resolve({ command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i; done' }))
+ await proc.done
+ const read = proc.readOutput()
// Window slid past offset 0 → lossy, spill path points at the full stream.
expect(read.lossy).toBe(true)
expect(read.stdoutSpillPath).toBeDefined()
})
- it('readOutput throws for unknown ids', async () => {
- const { bash } = await setup()
- expect(() => bash.readOutput(BashTaskId('nope'))).toThrow(/unknown bash task "nope"/)
- })
-
- it('kill terminates the process group and reports status killed', async () => {
- const { bash } = await setup()
- const task = bash.start(bash.resolve({ command: 'sleep 60' }))
- expect(bash.kill(task.id)).toBe(true)
- await task.done
- expect(task.status).toBe('killed')
- expect(task.signal).toBe('SIGTERM')
- })
-
- it('kill returns false for finished tasks and throws for unknown ids', async () => {
- const { bash } = await setup()
- const task = bash.start(bash.resolve({ command: 'true' }))
- await task.done
- expect(bash.kill(task.id)).toBe(false)
- expect(() => bash.kill(BashTaskId('nope'))).toThrow(/unknown bash task "nope"/)
- })
-
- it('notifies onTaskDone listeners on completion', async () => {
- const { bash } = await setup()
- const seen: [string, string][] = []
- bash.onTaskDone(task => void seen.push([task.id, task.status]))
- const task = bash.start(bash.resolve({ command: 'true' }))
- await task.done
- expect(seen).toEqual([[task.id, 'completed']])
- })
-
- it('notifies onTaskDone for killed tasks too', async () => {
- const { bash } = await setup()
- const listener = vi.fn()
- bash.onTaskDone(listener)
- const task = bash.start(bash.resolve({ command: 'sleep 60' }))
- bash.kill(task.id)
- await task.done
- expect(listener).toHaveBeenCalledWith(task)
- expect(task.status).toBe('killed')
- })
-
- it('marks tasks killed when the background spawn itself fails', async () => {
- const { bash } = await setup()
- const listener = vi.fn()
- bash.onTaskDone(listener)
- const task = bash.start(bash.resolve({ command: 'true', workdir: '/nonexistent-dsh' }))
- await task.done
- expect(task.status).toBe('killed')
- expect(listener).toHaveBeenCalledWith(task)
- expect(bash.readOutput(task.id).delta).toContain('spawn failed')
- })
-
- it('readOutput adds a separator only when stdout lacks a trailing newline', async () => {
- const { bash } = await setup()
- const task = bash.start(bash.resolve({ command: 'printf out; echo err >&2' }))
- await task.done
- expect(bash.readOutput(task.id).delta).toBe('out\n[stderr]\nerr\n')
- })
-
it('readOutput reports stderr spill paths', async () => {
const { bash } = await setup({ maxOutputBytes: 100 })
- const task = bash.start(bash.resolve({ command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i >&2; done' }))
- await task.done
- const read = bash.readOutput(task.id)
+ const proc = bash.start(bash.resolve({ command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i >&2; done' }))
+ await proc.done
+ const read = proc.readOutput()
expect(read.lossy).toBe(true)
expect(read.stderrSpillPath).toBeDefined()
expect(read.delta).toContain('[stderr]')
})
- it('disposing with already-finished tasks only kills the running ones', async () => {
+ it('kill() terminates the process group: true once, false after settlement', async () => {
+ const { bash } = await setup()
+ const proc = bash.start(bash.resolve({ command: 'sleep 60' }))
+ expect(proc.kill()).toBe(true)
+ await proc.done
+ expect(proc.status).toBe('killed')
+ expect(proc.signal).toBe('SIGTERM')
+ expect(proc.kill()).toBe(false)
+ })
+
+ it('kill() returns false for a naturally completed process', async () => {
+ const { bash } = await setup()
+ const proc = bash.start(bash.resolve({ command: 'true' }))
+ await proc.done
+ expect(proc.status).toBe('completed')
+ expect(proc.kill()).toBe(false)
+ })
+
+ it('kill escalation uses the configured graceMs (a TERM-trapping process dies by SIGKILL)', async () => {
+ const { bash } = await setup() // setup pins graceMs: 200 via config
+ // The child echoes AFTER arming the trap, so waiting for the marker
+ // guarantees SIGTERM is already ignored when the kill lands (a fixed sleep
+ // is load-flaky: a slow spawn would take the SIGTERM before the trap).
+ const proc = bash.start(bash.resolve({ command: 'trap \'\' TERM; echo armed; sleep 60' }))
+ await readUntil(proc, 'armed')
+ proc.kill()
+ await proc.done
+ expect(proc.status).toBe('killed')
+ expect(proc.signal).toBe('SIGKILL')
+ })
+
+ it('a spec.signal abort settles the handle as killed, not completed', async () => {
+ const { bash } = await setup()
+ const controller = new AbortController()
+ const proc = bash.start(bash.resolve({ command: 'sleep 60', signal: controller.signal }))
+ controller.abort()
+ await proc.done
+ expect(proc.status).toBe('killed')
+ expect(proc.signal).toBe('SIGTERM')
+ })
+
+ it('a background spawn failure settles as killed with the error readable on stderr', async () => {
+ const { bash } = await setup()
+ const proc = bash.start(bash.resolve({ command: 'true', workdir: '/nonexistent-dsh' }))
+ // done resolves (never rejects) even though the process never ran.
+ await expect(proc.done).resolves.toBeUndefined()
+ expect(proc.status).toBe('killed')
+ expect(proc.readOutput().delta).toContain('spawn failed:')
+ })
+})
+
+describe('LocalBashExecutor disposal', () => {
+ it('disposing the fiber kills running processes and AWAITS their exit (no orphans, SIGKILL escalation included)', async () => {
const ctx = new Context()
const fiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 })
const bash = ctx.bash as LocalBashExecutor
bash.internals = { spillDir }
- const finished = bash.start(bash.resolve({ command: 'true' }))
+ // 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 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.
+ expect(() => process.kill(pid, 0)).toThrow()
+ expect(proc.status).toBe('killed')
+ await proc.done
+ })
+
+ it('settled processes already left the live map: dispose does not touch them', async () => {
+ const ctx = new Context()
+ const fiber = 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' }))
await fiber.dispose()
- await running.done
+ // The teardown marks every LIVE entry killed; a settled process had
+ // already left the map, so its status stays completed.
expect(finished.status).toBe('completed')
+ expect(running.status).toBe('killed')
+ await running.done
expect(running.signal).toBe('SIGTERM')
- expect(bash.list()).toEqual([])
- })
-
- it('disposing the executor fiber kills running tasks (no orphans)', async () => {
- const ctx = new Context()
- const fiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 })
- const bash = ctx.bash as LocalBashExecutor
- bash.internals = { spillDir }
- const listener = vi.fn()
- bash.onTaskDone(listener)
-
- const task = bash.start(bash.resolve({ command: 'sleep 60' }))
- const running = bash.get(task.id)!
- await new Promise(resolve => setTimeout(resolve, 50))
-
- // Grab the pid before dispose clears the registry.
- const pid = (running as unknown as { running: { pid: number } }).running.pid
- await fiber.dispose()
- await waitGone(pid)
- expect(bash.list()).toEqual([])
- // Listener silenced by base-class teardown — no late notifications.
- expect(listener).not.toHaveBeenCalled()
- })
-})
-
-describe('review fixes: lifecycle hardening', () => {
- it('start honors a pre-aborted or later-aborted AbortSignal', async () => {
- const { bash } = await setup()
- const controller = new AbortController()
- const task = bash.start(bash.resolve({ command: 'sleep 60', signal: controller.signal }))
- controller.abort()
- await task.done
- expect(task.status).toBe('killed')
- expect(task.signal).toBe('SIGTERM')
- })
-
- it('a throwing onTaskDone listener does not reject task.done or starve later listeners', async () => {
- const { bash } = await setup()
- const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
- const second = vi.fn()
- try {
- bash.onTaskDone(() => { throw new Error('listener bug') })
- bash.onTaskDone(second)
- const task = bash.start(bash.resolve({ command: 'true' }))
- await expect(task.done).resolves.toBeUndefined()
- expect(second).toHaveBeenCalledWith(task)
- expect(errorSpy).toHaveBeenCalled()
- } finally {
- errorSpy.mockRestore()
- }
- })
-
- it('dispose AWAITS a TERM-trapping process (SIGKILL escalation included)', async () => {
- const ctx = new Context()
- const fiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 })
- const bash = ctx.bash as LocalBashExecutor
- bash.internals = { spillDir }
-
- const task = bash.start(bash.resolve({ command: 'trap \'\' TERM; sleep 60' }))
- await new Promise(resolve => setTimeout(resolve, 100))
- const pid = (task as unknown as { running: { pid: number } }).running.pid
-
- await fiber.dispose()
- // Disposal itself waited: the pid must already be gone, no grace left.
- expect(() => process.kill(pid, 0)).toThrow()
- expect(task.status).toBe('killed')
})
})
diff --git a/packages/bash/bash/README.md b/packages/bash/bash/README.md
index 39318ae371..d297dad506 100644
--- a/packages/bash/bash/README.md
+++ b/packages/bash/bash/README.md
@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-bash
-The **bash executor seam**: an abstract `BashExecutor` service (`ctx.bash`) defining WHAT a bash backend does — run commands, manage background tasks — without saying HOW.
+The **bash executor seam**: an abstract `BashExecutor` service (`ctx.bash`) defining WHAT a bash backend does — run foreground commands, start background processes — without saying HOW.
This package is one third of the bash capability, split so each concern can evolve (and be swapped) independently:
@@ -8,7 +8,7 @@ This package is one third of the bash capability, split so each concern can evol
|---|---|
| `@deepseek-ai/dsh-bash` (this) | the interface: abstract service + vocabulary types |
| `@deepseek-ai/dsh-bash-local` | an implementation: local subprocesses |
-| `@deepseek-ai/dsh-tool-bash` | the model-facing tool schemas over `ctx.bash` |
+| `@deepseek-ai/dsh-tool-bash` | the model-facing tool schema over `ctx.bash` |
The split mirrors the LLM seam (`LlmService`/`LlmAdapter`) and the agent-tool survey: pi hides execution behind a `BashOperations` interface (local shell / SSH / VM backends), Codex behind an exec-server protocol. A future sandboxed, containerized, or remote executor implements this interface and the tool schemas don't change.
@@ -16,18 +16,14 @@ The split mirrors the LLM seam (`LlmService`/`LlmAdapter`) and the agent-tool su
| Member | Semantics |
|---|---|
+| `resolve(request)` | Fill a caller's `BashExecRequest` (optional `workdir`/`timeoutMs`) into a fully-resolved `BashExecSpec` from the implementation's config defaults and caps — the explicit defaulting step consumers call before `run`/`start`. |
| `run(spec)` | Foreground execution. Resolves when the command finishes. **Rejects only for infrastructure failures** (unusable workdir, missing shell, pre-aborted signal); nonzero exits, timeout kills, and abort kills resolve with a descriptive `BashRunResult`. |
-| `start(spec)` | Background execution. Returns a `BashTask` handle immediately; **no timeout applies** (stop tasks via `kill`). |
-| `get(id)` / `list()` | Task lookup. |
-| `ownerOf(id)` | The opaque OWNER token recorded for a background task at `start` (from the spec's `owner`), or `undefined` for an unknown id OR a known-but-ownerless task. The executor stores/returns it verbatim and NEVER interprets it — the access POLICY lives in the consumer (`dsh-tool-bash`), which compares `ownerOf(id)` to the caller's token. Storing ownership here (disposed with the executor's fiber) is what makes it survive a consumer HMR reload. |
-| `readOutput(id)` | **Incremental** output read — consecutive reads never re-deliver. Reads that lost data to buffer bounds flag `lossy` and point at full-stream spill files. Throws for unknown ids. |
-| `kill(id)` | Kill a running task. Returns `false` when it already finished; throws for unknown ids. |
-| `onTaskDone(listener)` | Completion listener (effect-based, disposer returned). Fires exactly once per task; never after the service is disposed. |
+| `start(spec)` | Background execution. Returns a `BashProcess` handle immediately; **no timeout applies** (stop the process via the handle's `kill()` or the spec's AbortSignal). |
-Implementations subclass `BashExecutor`, implement the abstract methods, and call `notifyTaskDone(task)` on background completion. Disposal must kill every running task (no orphan processes) — see the HMR-safety tests.
+The seam is deliberately TASK-FREE: `start()` hands back only the `BashProcess` handle — `command`, `status`, `exitCode`/`signal`, a never-rejecting `done` quiescence promise, a consuming incremental `readOutput()` (reads that lost data to buffer bounds flag `lossy` and point at full-stream spill files; reads stay valid after exit), and an idempotent `kill()`. Task ids, cross-session isolation, polling tools, and completion notices are the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md)'s job — the tool layer adapts the handle into a task registration — which keeps a remote/sandbox executor free of any session or registry dependency. Disposal must kill every running background process and await its exit (no orphan processes) — see the HMR-safety tests.
## Vocabulary
-`BashExecRequest` (command, workdir?, timeoutMs?, signal?, stdin?, env?, owner?) resolves to `BashExecSpec` (command, workdir, timeoutMs, signal?, stdin?, env?, owner) before execution; `owner` is optional on the request and **required-but-nullable** (`OwnerToken | undefined`) on the resolved spec, so a forgotten owner is a visible `undefined` rather than a silently-absent property. The task id (`BashTaskId`) and the `owner` token (`OwnerToken`) are [branded](../../util/brand) — `OwnerToken` is a DISTINCT brand from `SessionId` (the seam never imports `dsh-session`; the `dsh-tool-bash` consumer is the single boundary that casts its `SessionId` into one). `run()` returns `BashRunResult` (exitCode, signal, timedOut, aborted, timeoutMs, stdout/stderr as `CollectedOutput`) and `start()`/`readOutput()` use `BashTask`/`BashTaskRead` for the background side. See `src/types.ts` for the full contracts.
+`BashExecRequest` (command, workdir?, timeoutMs?, signal?, stdin?, env?) resolves to `BashExecSpec` (command, workdir, timeoutMs, signal?, stdin?, env?) before execution. `run()` returns `BashRunResult` (exitCode, signal, timedOut, aborted, timeoutMs, stdout/stderr as `CollectedOutput`) and `start()` returns `BashProcess`, whose `readOutput()` yields a `BashProcessRead`. See `src/types.ts` for the full contracts.
-`stdin` and `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload on stdin and its `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` env. The model-facing `dsh-tool-bash` tool does not expose them as parameters — a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), so they would be redundant tool params. This is not a security boundary: the implementation's credential scrub (not these fields) is what keeps the harness's ambient secrets out of a spawned command. They are plain optionals on the resolved spec (unlike `owner`'s required-but-nullable): a missing one means "none", the safe default. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
+`stdin` and `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload on stdin and its `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` env. The model-facing `dsh-tool-bash` tool does not expose them as parameters — a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), so they would be redundant tool params. This is not a security boundary: the implementation's credential scrub (not these fields) is what keeps the harness's ambient secrets out of a spawned command. They are plain optionals on the resolved spec: a missing one means "none", the safe default. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
diff --git a/packages/bash/bash/package.json b/packages/bash/bash/package.json
index f1bc43c2b4..865de7b643 100644
--- a/packages/bash/bash/package.json
+++ b/packages/bash/bash/package.json
@@ -22,11 +22,9 @@
],
"license": "BSD-3-Clause",
"peerDependencies": {
- "@deepseek-ai/dsh-brand": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
- "@deepseek-ai/dsh-brand": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}
diff --git a/packages/bash/bash/src/index.ts b/packages/bash/bash/src/index.ts
index b629f10e4d..af0d032965 100644
--- a/packages/bash/bash/src/index.ts
+++ b/packages/bash/bash/src/index.ts
@@ -1,31 +1,36 @@
/**
* The bash executor seam (`ctx.bash`): an abstract service defining WHAT a
- * bash backend does — run commands, manage background tasks — without saying
- * HOW. Implementations subclass {@link BashExecutor} and register themselves
- * as the `bash` service; `@deepseek-ai/dsh-bash-local` (local subprocesses)
- * is the first. Future implementations swap in sandboxes, containers, or
- * remote exec servers without touching the tool schemas that consume them
- * (`@deepseek-ai/dsh-tool-bash`).
+ * bash backend does — run foreground commands, start background processes —
+ * without saying HOW. Implementations subclass {@link BashExecutor} and
+ * register themselves as the `bash` service; `@deepseek-ai/dsh-bash-local`
+ * (local subprocesses) is the first. Future implementations swap in
+ * sandboxes, containers, or remote exec servers without touching the tool
+ * schemas that consume them (`@deepseek-ai/dsh-tool-bash`).
*
* The split mirrors the LLM seam (`LlmService`/`LlmAdapter`) and the
* surveyed agents: pi hides execution behind a `BashOperations` interface
* (local shell / SSH / VM backends), Codex behind an exec-server protocol.
*
+ * The seam is deliberately TASK-FREE: `start()` hands back a
+ * {@link BashProcess} handle (incremental reads, kill, a quiescence promise)
+ * and nothing else. Task ids, owner isolation, polling tools, and completion
+ * notices are the generic `ctx.tasks` runtime's job (`@deepseek-ai/dsh-tasks`)
+ * — the tool layer adapts the handle into a task registration. This keeps a
+ * remote/sandbox executor free of any session or registry dependency.
+ *
* @module @deepseek-ai/dsh-bash
*/
import { Context, Service } from 'cordis'
-import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskId, BashTaskListener, BashTaskRead, OwnerToken } from './types.ts'
+import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from './types.ts'
-export { BashTaskId, OwnerToken } from './types.ts'
export type {
BashExecRequest,
BashExecSpec,
+ BashProcess,
+ BashProcessRead,
+ BashProcessStatus,
BashRunResult,
- BashTask,
- BashTaskListener,
- BashTaskRead,
- BashTaskStatus,
CollectedOutput,
} from './types.ts'
@@ -47,27 +52,19 @@ declare module 'cordis' {
* abort kills RESOLVE with a descriptive {@link BashRunResult} — reporting
* a failed command is the tool layer's job, not an exception.
* - {@link start} returns immediately; no timeout applies to background
- * tasks (callers stop them via {@link kill} or the spec's AbortSignal).
- * Completion must fire the {@link onTaskDone} listeners exactly once per
- * task, and must NOT fire after the service is disposed.
- * - {@link readOutput} is incremental: consecutive reads never re-deliver
- * output. Implementations bound their buffers; reads that lost data flag
- * `lossy` and point at full-stream spill files when available.
- * - Disposal kills every running task and awaits their exit (no orphan
- * processes survive `fiber.dispose()`).
+ * processes (callers stop them via {@link BashProcess.kill} or the spec's
+ * AbortSignal). The handle's `done` settles at process close and never
+ * rejects (a spawn failure settles as `killed` with the error readable on
+ * stderr).
+ * - {@link BashProcess.readOutput} is incremental: consecutive reads never
+ * re-deliver output. Implementations bound their buffers; reads that lost
+ * data flag `lossy` and point at full-stream spill files when available.
+ * - Disposal kills every running background process and awaits their exit
+ * (no orphan processes survive `fiber.dispose()`).
*/
export abstract class BashExecutor extends Service {
- private listeners = new Set()
- private listenersClosed = false
-
constructor(ctx: Context) {
super(ctx, 'bash')
- ctx.effect(() => () => {
- // Close the listener registry before subclass teardown so late task
- // completions (e.g. from kills issued during dispose) stay silent.
- this.listenersClosed = true
- this.listeners.clear()
- }, 'bash listener teardown')
}
/**
@@ -92,86 +89,11 @@ export abstract class BashExecutor extends Service {
abstract run(spec: BashExecSpec): Promise
/**
- * Start a background task and return its handle immediately.
+ * Start a background process and return its handle immediately.
* @param spec - a resolved spec from {@link resolve}, never a raw request.
- * @returns the live task handle; completion fires {@link onTaskDone}.
+ * @returns the live process handle (reads, kill, quiescence promise).
*/
- abstract start(spec: BashExecSpec): BashTask
-
- /**
- * Look up a background task by id.
- * @param id - the task id to look up.
- * @returns the tracked task, or undefined for an id this executor never issued.
- */
- abstract get(id: BashTaskId): BashTask | undefined
-
- /**
- * The opaque OWNER token recorded for a background task at {@link start}
- * (from the {@link BashExecSpec}'s `owner`), or `undefined` for an unknown id
- * OR a known-but-ownerless task. The executor stores and returns the token
- * verbatim — it never interprets it; the access POLICY (who may read/kill a
- * task) lives in the consumer (`@deepseek-ai/dsh-tool-bash`), which compares
- * `ownerOf(id)` to the caller's token. Collapsing unknown-id and
- * known-but-unowned into the same `undefined` is fine: the consumer's access
- * gate treats `undefined` as "open", and a genuinely unknown id then fails
- * loudly at the subsequent {@link readOutput}/{@link kill} ("unknown task").
- * Storing ownership in the executor (disposed with ITS fiber) — not in the
- * tool plugin — is what makes ownership survive a `tool-bash` HMR reload.
- * @param id - the background task id to look up ownership for.
- * @returns the token recorded at start, verbatim; undefined for an unknown
- * id or a known-but-ownerless task.
- */
- abstract ownerOf(id: BashTaskId): OwnerToken | undefined
-
- /**
- * All tracked background tasks (insertion order).
- * @returns every task this executor started, running or finished.
- */
- abstract list(): BashTask[]
-
- /**
- * Read output produced since the previous read. Throws for unknown ids.
- * @param id - the task to read from.
- * @returns the incremental read; consecutive reads never re-deliver output.
- */
- abstract readOutput(id: BashTaskId): BashTaskRead
-
- /**
- * Kill a running background task. Returns false when it had already
- * finished (no-op). Throws for unknown ids.
- * @param id - the task to kill.
- * @returns true when this call killed it, false when it had already finished.
- */
- abstract kill(id: BashTaskId): boolean
-
- /**
- * Register a background-task completion listener (disposed with the
- * calling fiber). Listeners never fire after this service is disposed.
- * @param listener - called exactly once per task completion.
- * @returns the disposer that unregisters the listener.
- */
- onTaskDone(listener: BashTaskListener): () => void {
- const dispose = this.ctx.effect(() => {
- this.listeners.add(listener)
- return () => this.listeners.delete(listener)
- }, 'bash.onTaskDone()')
- return () => void dispose()
- }
-
- /** For implementations: notify listeners that `task` completed. Listener
- * exceptions are contained (logged) — one bad listener must not reject
- * `BashTask.done` or starve the listeners after it. */
- protected notifyTaskDone(task: BashTask): void {
- if (this.listenersClosed) return
- for (const listener of this.listeners) {
- try {
- listener(task)
- } catch (error: unknown) {
- // Listener bugs are reported, never propagated into task.done.
- console.error('bash onTaskDone listener threw:', error)
- }
- }
- }
+ abstract start(spec: BashExecSpec): BashProcess
}
export default BashExecutor
diff --git a/packages/bash/bash/src/types.ts b/packages/bash/bash/src/types.ts
index 4715ace318..c5031fdcee 100644
--- a/packages/bash/bash/src/types.ts
+++ b/packages/bash/bash/src/types.ts
@@ -3,43 +3,15 @@
* service lives in `./index.ts`, implementations in sibling packages
* (`@deepseek-ai/dsh-bash-local` first).
*
+ * Background TASK semantics (ids, ownership, polling protocol, completion
+ * listeners) deliberately do NOT live here: the seam starts a background
+ * PROCESS and returns a {@link BashProcess} handle; the caller (the tool
+ * layer) registers that handle with the generic `ctx.tasks` runtime
+ * (`@deepseek-ai/dsh-tasks`), which owns everything task-shaped.
+ *
* @module dsh-bash/types
*/
-import type { Branded } from '@deepseek-ai/dsh-brand'
-
-/** Identifies one background task within an executor (generated `bash-N`). */
-export type BashTaskId = Branded<'BashTaskId'>
-
-/**
- * Brand a string as a {@link BashTaskId}.
- * @param id - the raw task-id string (the executor generates `bash-N`).
- * @returns the same string, branded; no validation is performed.
- */
-export function BashTaskId(id: string): BashTaskId {
- return id as BashTaskId
-}
-
-/**
- * A background task's opaque isolation key — the CONSUMER's owner identity, not
- * the bash seam's. The executor stores and returns it verbatim and never
- * interprets it; the access policy lives in the consumer (`dsh-tool-bash`),
- * which is the single boundary that casts its own id vocabulary into one. A
- * DISTINCT brand (not a `SessionId` alias) keeps the seam decoupled — a
- * sandboxed/remote executor inherits no session dependency.
- */
-export type OwnerToken = Branded<'OwnerToken'>
-
-/**
- * Brand a string as an {@link OwnerToken}. Only the consuming boundary
- * (`dsh-tool-bash`) should cast its own id vocabulary in — see the type's doc.
- * @param id - the consumer's raw owner identity (the tool layer passes the owning agent's session id).
- * @returns the same string, branded; no validation is performed.
- */
-export function OwnerToken(id: string): OwnerToken {
- return id as OwnerToken
-}
-
/**
* A caller's execution REQUEST: `workdir` and `timeoutMs` are optional and
* filled by {@link BashExecutor.resolve} from the implementation's config.
@@ -72,15 +44,6 @@ export interface BashExecRequest {
* uses shell syntax like `FOO=bar cmd`).
*/
env?: Record | undefined
- /**
- * Opaque OWNER token for a background task — the consumer's isolation key
- * (the tool layer passes the owning agent's `session.header.id`). The
- * executor stores it on the task and exposes it via {@link BashExecutor.ownerOf};
- * the executor itself NEVER interprets it (no access policy lives in the
- * seam — that is the consumer's job). Absent for foreground runs and for an
- * ownerless background start (a non-agent caller).
- */
- owner?: OwnerToken | undefined
}
/**
@@ -88,7 +51,7 @@ export interface BashExecRequest {
* {@link BashExecutor.start} act on. `workdir` and `timeoutMs` are REQUIRED:
* defaulting and capping already happened in {@link BashExecutor.resolve}, so
* the executor never hides a `?? config` fallback (explicit > implicit). For
- * background tasks, `start()` ignores `timeoutMs` (background runs have no
+ * background processes, `start()` ignores `timeoutMs` (background runs have no
* timeout) — the field is still required because the type is shared.
*/
export interface BashExecSpec {
@@ -99,10 +62,10 @@ export interface BashExecSpec {
signal?: AbortSignal | undefined
/**
* Bytes to write to the command's stdin (then close it), carried through
- * verbatim from {@link BashExecRequest.stdin}. OPTIONAL on the resolved spec
- * (unlike `owner`): it has no config default, so a missing one means "no
- * stdin" — the safe, ordinary case — not a silent footgun, so it stays a
- * plain optional rather than required-but-nullable (see the request field).
+ * verbatim from {@link BashExecRequest.stdin}. OPTIONAL on the resolved spec:
+ * it has no config default, so a missing one means "no stdin" — the safe,
+ * ordinary case — not a silent footgun, so it stays a plain optional rather
+ * than required-but-nullable (see the request field).
*/
stdin?: string | undefined
/**
@@ -113,15 +76,6 @@ export interface BashExecSpec {
* config default, absent means "no extra env".
*/
env?: Record | undefined
- /**
- * Opaque owner token, REQUIRED-but-nullable (mirrors `workdir`/`timeoutMs`
- * being required on the resolved spec): {@link BashExecutor.resolve} carries
- * the request's `owner` through, defaulting a missing one to `undefined`. A
- * required field makes a forgotten owner a VISIBLE `undefined` rather than a
- * silently-absent property that yields an unowned (cross-session-readable)
- * task. `start()` stores it; `run()` (foreground) ignores it.
- */
- owner: OwnerToken | undefined
}
/** One captured stream: the (possibly truncated) text plus recovery info. */
@@ -150,25 +104,11 @@ export interface BashRunResult {
stderr: CollectedOutput
}
-/** Lifecycle of a background task. */
-export type BashTaskStatus = 'running' | 'completed' | 'killed'
+/** Lifecycle of a background process. */
+export type BashProcessStatus = 'running' | 'completed' | 'killed'
-/** A tracked background task handle. */
-export interface BashTask {
- readonly id: BashTaskId
- readonly command: string
- status: BashTaskStatus
- /** Exit code once finished (null = killed by signal / still running). */
- exitCode: number | null
- /** Terminating signal name, when signal-killed. */
- signal: NodeJS.Signals | null
- /** Resolves when the underlying process closes (never rejects). */
- readonly done: Promise
-}
-
-/** One incremental {@link BashExecutor.readOutput} read. */
-export interface BashTaskRead {
- task: BashTask
+/** One incremental {@link BashProcess.readOutput} read. */
+export interface BashProcessRead {
/** Output produced since the previous read (stderr in a marked section). */
delta: string
/** True when truncation dropped unread bytes the delta cannot include. */
@@ -179,5 +119,34 @@ export interface BashTaskRead {
stderrSpillPath?: string
}
-/** Completion callback for background tasks. */
-export type BashTaskListener = (task: BashTask) => void
+/**
+ * A live background process handle, returned by {@link BashExecutor.start}.
+ * The HANDLE is the only access path (no executor-level id lookup): the
+ * caller holds it, adapts it into a `ctx.tasks` registration, or drops it.
+ * Reads stay valid after the process exits (the remaining buffered output is
+ * still consumable); the executor's own disposal kills every running process
+ * and awaits {@link done}.
+ */
+export interface BashProcess {
+ /** The command line this process runs. */
+ readonly command: string
+ /** Process lifecycle state (settled exactly once). */
+ status: BashProcessStatus
+ /** Exit code once finished (null = killed by signal / still running). */
+ exitCode: number | null
+ /** Terminating signal name, when signal-killed. */
+ signal: NodeJS.Signals | null
+ /** Resolves when the underlying process closes (never rejects — a spawn failure settles as `killed` with the error on stderr). */
+ readonly done: Promise
+ /**
+ * Read output produced since the previous read (consuming — consecutive
+ * reads never re-deliver). Reads that lost data flag `lossy` and point at
+ * full-stream spill files when available.
+ */
+ readOutput(): BashProcessRead
+ /**
+ * Kill the process group. Returns false when it had already finished
+ * (no-op); idempotent.
+ */
+ kill(): boolean
+}
diff --git a/packages/bash/bash/tests/service.spec.ts b/packages/bash/bash/tests/service.spec.ts
index 81530843ed..e1ab24da84 100644
--- a/packages/bash/bash/tests/service.spec.ts
+++ b/packages/bash/bash/tests/service.spec.ts
@@ -1,145 +1,78 @@
-import { describe, expect, it, vi } from 'vitest'
+import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
-import { BashExecutor, BashTaskId, OwnerToken } from '@deepseek-ai/dsh-bash'
-import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead } from '@deepseek-ai/dsh-bash'
+import { BashExecutor } from '@deepseek-ai/dsh-bash'
+import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult } from '@deepseek-ai/dsh-bash'
-/** Minimal concrete executor: records calls, lets tests drive completions. */
+/**
+ * Minimal concrete executor: canned foreground results, a hand-built process
+ * handle. The seam is TASK-FREE (start returns a {@link BashProcess} handle;
+ * task semantics live in `ctx.tasks`), so this stub is all an implementation
+ * owes the abstract class.
+ */
class StubExecutor extends BashExecutor {
- tasks = new Map()
- private owners = new Map()
-
resolve(request: BashExecRequest): BashExecSpec {
return {
command: request.command,
workdir: request.workdir ?? '/stub',
timeoutMs: request.timeoutMs ?? 1000,
...request.signal ? { signal: request.signal } : {},
- owner: request.owner,
}
}
- async run(_spec: BashExecSpec): Promise {
+ async run(spec: BashExecSpec): Promise {
return {
exitCode: 0,
signal: null,
timedOut: false,
aborted: false,
- timeoutMs: 1000,
+ timeoutMs: spec.timeoutMs,
stdout: { text: 'ok', truncated: false },
stderr: { text: '', truncated: false },
}
}
- start(spec: BashExecSpec): BashTask {
- const task: BashTask = {
- id: BashTaskId(`stub-${this.tasks.size + 1}`),
+ start(spec: BashExecSpec): BashProcess {
+ const proc: BashProcess = {
command: spec.command,
status: 'running',
exitCode: null,
signal: null,
done: Promise.resolve(),
+ readOutput: (): BashProcessRead => ({ delta: '', lossy: false }),
+ kill: (): boolean => {
+ if (proc.status !== 'running') return false
+ proc.status = 'killed'
+ return true
+ },
}
- this.tasks.set(task.id, task)
- this.owners.set(task.id, spec.owner)
- return task
+ return proc
}
-
- get(id: BashTaskId): BashTask | undefined {
- return this.tasks.get(id)
- }
-
- ownerOf(id: BashTaskId): OwnerToken | undefined {
- return this.owners.get(id)
- }
-
- list(): BashTask[] {
- return [...this.tasks.values()]
- }
-
- readOutput(id: BashTaskId): BashTaskRead {
- const task = this.tasks.get(id)
- if (!task) throw new Error(`unknown bash task "${id}"`)
- return { task, delta: '', lossy: false }
- }
-
- kill(id: BashTaskId): boolean {
- const task = this.tasks.get(id)
- if (!task) throw new Error(`unknown bash task "${id}"`)
- if (task.status !== 'running') return false
- task.status = 'killed'
- return true
- }
-
- /** Expose the protected notifier for tests. */
- fire(task: BashTask): void {
- this.notifyTaskDone(task)
- }
-}
-
-async function setup() {
- const ctx = new Context()
- await ctx.plugin(StubExecutor)
- // ctx.bash resolves to the registered implementation.
- const bash = ctx.bash as StubExecutor
- return { ctx, bash }
}
describe('BashExecutor service seam', () => {
- it('registers as ctx.bash and serves the abstract API', async () => {
- const { bash } = await setup()
- const task = bash.start(bash.resolve({ command: 'sleep 1' }))
- expect(bash.get(task.id)).toBe(task)
- expect(bash.list()).toEqual([task])
- expect(bash.kill(task.id)).toBe(true)
- expect(bash.kill(task.id)).toBe(false)
- const result = await bash.run(bash.resolve({ command: 'true' }))
- expect(result.exitCode).toBe(0)
- })
-
- it('onTaskDone delivers completions to registered listeners', async () => {
- const { bash } = await setup()
- const seen: string[] = []
- bash.onTaskDone(task => void seen.push(task.id))
- const task = bash.start(bash.resolve({ command: 'x' }))
- bash.fire(task)
- expect(seen).toEqual([task.id])
- })
-
- it('onTaskDone disposer unsubscribes the listener', async () => {
- const { bash } = await setup()
- const listener = vi.fn()
- const dispose = bash.onTaskDone(listener)
- dispose()
- bash.fire(bash.start(bash.resolve({ command: 'x' })))
- expect(listener).not.toHaveBeenCalled()
- })
-
- it('listeners registered from a fiber are removed on dispose (HMR safety)', async () => {
- const { ctx, bash } = await setup()
- const listener = vi.fn()
- const fiber = await ctx.plugin(Object.assign((inner: Context) => {
- inner.bash.onTaskDone(listener)
- }, { inject: ['bash'] }))
- bash.fire(bash.start(bash.resolve({ command: 'one' })))
- expect(listener).toHaveBeenCalledTimes(1)
-
- await fiber.dispose()
- bash.fire(bash.start(bash.resolve({ command: 'two' })))
- expect(listener).toHaveBeenCalledTimes(1)
- })
-
- it('silences listeners once the service fiber is disposed', async () => {
+ it('a concrete subclass registers as ctx.bash and serves the abstract API', async () => {
const ctx = new Context()
- const fiber = await ctx.plugin(Object.assign(async (inner: Context) => {
- await inner.plugin(StubExecutor)
- }, {}))
- const bash = ctx.bash as StubExecutor
- const listener = vi.fn()
- bash.onTaskDone(listener)
- const task = bash.start(bash.resolve({ command: 'x' }))
+ await ctx.plugin(StubExecutor)
+ const spec = ctx.bash.resolve({ command: 'echo hi' })
+ expect(spec).toEqual({ command: 'echo hi', workdir: '/stub', timeoutMs: 1000 })
- await fiber.dispose()
- bash.fire(task)
- expect(listener).not.toHaveBeenCalled()
+ const result = await ctx.bash.run(spec)
+ expect(result.exitCode).toBe(0)
+ expect(result.stdout.text).toBe('ok')
+
+ const proc = ctx.bash.start(spec)
+ expect(proc.command).toBe('echo hi')
+ expect(proc.status).toBe('running')
+ expect(proc.readOutput()).toEqual({ delta: '', lossy: false })
+ expect(proc.kill()).toBe(true)
+ expect(proc.kill()).toBe(false) // already settled → no-op
+ await proc.done
+ })
+
+ it('loading a second implementation throws (one bash service per context — cordis standard)', async () => {
+ const ctx = new Context()
+ await ctx.plugin(StubExecutor)
+ class SecondExecutor extends StubExecutor {}
+ await expect(ctx.plugin(SecondExecutor)).rejects.toThrow(/service "bash" has been registered/)
})
})
diff --git a/packages/bash/bash/tsconfig.json b/packages/bash/bash/tsconfig.json
index 342f636170..754725418e 100644
--- a/packages/bash/bash/tsconfig.json
+++ b/packages/bash/bash/tsconfig.json
@@ -13,9 +13,6 @@
},
{
"path": "../../../vendor/cordis"
- },
- {
- "path": "../../util/brand"
}
]
}
diff --git a/packages/bash/tool-bash/README.md b/packages/bash/tool-bash/README.md
index eabb9298aa..44bc2f1b35 100644
--- a/packages/bash/tool-bash/README.md
+++ b/packages/bash/tool-bash/README.md
@@ -1,14 +1,18 @@
# @deepseek-ai/dsh-tool-bash
-The model-facing bash tools — `bash`, `bash_output`, `bash_kill` — registered over the `ctx.bash` executor seam (`@deepseek-ai/dsh-bash`). Pure schema + text shaping; every process concern lives behind the seam, so sandboxed or remote executor implementations swap in without changing what the model sees.
+The model-facing `bash` tool, registered over the `ctx.bash` executor seam (`@deepseek-ai/dsh-bash`). Pure schema + text shaping; every process concern lives behind the seam, so sandboxed or remote executor implementations swap in without changing what the model sees. Background runs are generic tasks: the tool registers the started process with `ctx.tasks` (`@deepseek-ai/dsh-tasks`), and the model collects/stops them through the shared `task_output`/`task_list`/`task_kill` tools (`@deepseek-ai/dsh-tool-tasks`) — this package registers no companion tools of its own.
-Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`); the plugin stays pending until `ctx.bash` exists (`inject: ['tools', 'bash', 'systemPrompt']`).
+Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`); the plugin stays pending until `ctx.bash` exists (`inject: ['tools', 'bash', 'systemPrompt']`). The `ctx.tasks` runtime is looked up at call time: a background call without it fails loud (`background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`).
The plugin also contributes the `tool:bash` prompt section (order 105) — the cross-call habit the per-tool descriptions cannot carry: check the `[exit code: N]` marker on every result and investigate failures before moving on.
-## Tools
+## Config
-### `bash`
+| key | default | meaning |
+|---|---|---|
+| `enableRunInBackground` | `true` | Expose `run_in_background` in the schema. Disabled, the parameter is absent entirely (schema and capability never disagree) and the description says background execution is unavailable. |
+
+## The `bash` tool
| Arg | Type | Notes |
|---|---|---|
@@ -16,35 +20,23 @@ The plugin also contributes the `tool:bash` prompt section (order 105) — the c
| `description` | string (required) | One-line, active-voice summary of the command (5-10 words), for UI/log display only — no effect on execution. |
| `timeoutMs` | number | Timeout override in milliseconds. The executor applies its configured default and cap. |
| `workdir` | string | Working directory for this call. Defaults to the calling agent's session cwd (`session.header.cwd`) so each session runs in its own workspace; a relative `workdir` is resolved against that session cwd. |
-| `run_in_background` | boolean | Return a task id immediately; no timeout applies. |
+| `run_in_background` | boolean | Return a task id immediately; no timeout applies. Present only when `enableRunInBackground` allows. |
`command`, `workdir`, and `timeoutMs` are resolved against the executor's config defaults via `ctx.bash.resolve()` before execution, so the executor seam (`BashExecSpec`) receives explicit `workdir`/`timeoutMs` values. The workdir default is applied in the tool layer (from the calling agent's `session.header.cwd`) BEFORE `resolve()` — the per-session cwd must come from `exec.agent`, since N sessions share one executor; only when no session cwd is available does the executor fall back to its own config / `process.cwd()`.
-Result text: stdout, then a `[stderr]` section, then status markers — `[timed out after Nms]` whenever the executor's timer fired (reported independently of how the process ended, so a command that traps SIGTERM and exits 0 still shows it), `[killed by signal: …]` for a signal death, `[exit code: N]` for a non-zero exit (reported, **not** `isError`: the model decides how to react), and `[output truncated; full output: ]` when the tail was kept and a safe spill file is available. If the executor knows output was dropped but cannot safely advertise a complete spill file, the path is reported as `(unavailable)`. Only infrastructure failures (spawn errors, aborts) surface as `isError` results.
+Foreground result text: stdout, then a `[stderr]` section, then status markers — `[timed out after Nms]` whenever the executor's timer fired (reported independently of how the process ended, so a command that traps SIGTERM and exits 0 still shows it), `[killed by signal: …]` for a signal death, `[exit code: N]` for a non-zero exit (reported, **not** `isError`: the model decides how to react), and `[output truncated; full output: ]` when the tail was kept and a safe spill file is available. If the executor knows output was dropped but cannot safely advertise a complete spill file, the path is reported as `(unavailable)`. Only infrastructure failures (spawn errors, aborts) surface as `isError` results.
-### `bash_output`
+## Background runs as tasks
-`task_id` → output produced **since the previous `bash_output` call** plus a status line (`running` / `completed, exit code: N` / `killed`). Reads that lost data to buffer bounds say so and point at the full-output spill file when one is safely available, otherwise `(unavailable)`.
-
-### `bash_kill`
-
-`task_id` → ask the executor to kill the background task. The concrete executor decides how to signal or stop the process; killing an already-finished task is a reported no-op, and unknown ids are errors.
-
-### Task ownership (cross-session isolation)
-
-The owning agent's session token (`session.header.id`) is stamped onto the task at spawn — passed to the executor via `resolve({ …, owner })` and stored ON THE TASK inside the executor (the `dsh-bash` `ownerOf(id)` seam), **not** in a plugin-local map. `bash_output`/`bash_kill` compare `ctx.bash.ownerOf(id)` to the caller's token (`session.header.id`) with `!== undefined` semantics and reject a task owned by a *different* session with `task belongs to another session` (a task started with no agent — a non-loop caller — has no owner token and is open to anyone; a call with no `exec.agent` cannot access an owned task). Task ids are global and predictable, so under multi-session ACP this token check is the fence that stops one session's agent from reading or killing another session's background task. Because ownership lives on the task in the executor (disposed with the `dsh-bash` fiber), it **survives an independent `tool-bash` HMR reload** — closing the old plugin-local-map gap where a reload orphaned pre-reload tasks. (The `onTaskDone` listener is still effect-scoped to this plugin's `apply`, so a completion landing during the reload gap still drops its one notice — the pre-existing reload-gap drop — but the ownership fence itself is HMR-proof.)
+A `run_in_background` call refuses an already-aborted `exec.signal`, starts the process through the seam, registers `{ kind: 'bash', label: command, owner: exec.agent, cancel, done, readOutput }` with `ctx.tasks`, and returns `started background task `. The tool-call signal is deliberately NOT wired to the process after that — the parent step may end while the command runs; cancellation belongs to `task_kill` and the runtime's owner-disposal cleanup. The producer mapping is exported for tests: `processOutcome` (a killed process → `killed` with the signal as detail; everything else → `completed` with `exit code: N` — a nonzero exit is reported, not failed) and `renderProcessRead` (the incremental delta, plus a `[some output was dropped from memory; full output: …]` notice with spill paths on lossy reads). Ownership, isolation, listing, polling, waiting, kill semantics, and completion notices are all the task runtime's — see [`packages/tasks`](../../tasks/README.md).
## UI presentation
-These tools own how their calls render in a UI (an editor's tool-call card) via the `dsh-tools` `presentCall`/`presentResult` seam, each returning a `card`-tagged render intent — a UI never special-cases tool names. A FOREGROUND `bash` run declares a **terminal card**: `presentCall` returns `{ card: 'terminal', title, description?, cwd? }` — the **title** is the exact `command` ("ls -la src"), the model-written `description` rides along (rendered ABOVE the card), and `cwd` comes from the model `workdir` when given (absolute as-is, relative for the UI bridge to resolve against the session cwd; else left for the bridge to fill from the session cwd) — and `presentResult` returns `{ card: 'terminal', title?, output?, exitCode?, signal? }` carrying the raw output plus the parsed `exitCode`/`signal`, so a capable client (Zed) renders a terminal card with an exit-status pill. The result carries the raw `output`; the bridge DERIVES the ` ```console ` fenced fallback for a no-terminal-capability UI (the tool no longer encodes the fences itself), so the model-facing result text stays unfenced. A `run_in_background` call is NOT a terminal (it returns a task id immediately and never streams a terminal — poll with `bash_output`) and instead returns a **generic card** (`{ card: 'generic', title, kind: 'execute', rawInput: command, content: [description] }`); an `isError` result (spawn failure / abort) likewise returns a `generic` result view with no exit pill (there is no real process exit). `bash_output`/`bash_kill` return a `generic` card with a task-scoped title ("Read output from background task bash-3" / "Kill background task bash-3") and the task id as rawInput. These methods are pure/display-only (they also run on `session/load` replay), and a malformed/older logged arg shape falls back to a generic presentation rather than throwing. See `packages/core/tools` ("Tool-owned UI presentation") and `packages/ui/acp` ("Terminal card" / "Tool-call presentation").
-
-## Background completion notices
-
-When a background task finishes, a short notice is injected into the owning agent's session (`agent.inject()`, source `{kind: 'plugin', plugin: 'tool-bash'}`). The owning agent is found by its session token: the listener reads `ctx.bash.ownerOf(task.id)` and scans `ctx.get('agents')?.list()` for an agent whose `session.header.id` matches (read via `ctx.get` — `onTaskDone` runs on the bash fiber, a foreign fiber, so the `ctx.agents` proxy would throw). If no live agent carries that token — e.g. the owning session disconnected and its agent was disposed while the task ran on — the notice is dropped cleanly. Injection is **durable context for the next model request, not a wake-up** — an idle agent stays idle until something sends a message. That's why the tool descriptions tell the model to poll with `bash_output`.
+These tools own how their calls render in a UI (an editor's tool-call card) via the `dsh-tools` `presentCall`/`presentResult` seam, each returning a `card`-tagged render intent — a UI never special-cases tool names. A FOREGROUND `bash` run declares a **terminal card**: `presentCall` returns `{ card: 'terminal', title, description?, cwd? }` — the **title** is the exact `command` ("ls -la src"), the model-written `description` rides along (rendered ABOVE the card), and `cwd` comes from the model `workdir` when given (absolute as-is, relative for the UI bridge to resolve against the session cwd; else left for the bridge to fill from the session cwd) — and `presentResult` returns `{ card: 'terminal', title?, output?, exitCode?, signal? }` carrying the raw output plus the parsed `exitCode`/`signal`, so a capable client (Zed) renders a terminal card with an exit-status pill. The result carries the raw `output`; the bridge DERIVES the ` ```console ` fenced fallback for a no-terminal-capability UI (the tool no longer encodes the fences itself), so the model-facing result text stays unfenced. A `run_in_background` call is NOT a terminal (it returns a task id immediately and never streams a terminal — its output is read via `task_output`) and instead returns a **generic card** (`{ card: 'generic', title, kind: 'execute', rawInput: command, content: [description] }`); an `isError` result (spawn failure / abort) likewise returns a `generic` result view with no exit pill (there is no real process exit). These methods are pure/display-only (they also run on `session/load` replay), and a malformed/older logged arg shape falls back to a generic presentation rather than throwing. See `packages/core/tools` ("Tool-owned UI presentation") and `packages/ui/acp` ("Terminal card" / "Tool-call presentation").
## The tool builds its request from named args only
-The `BashExecRequest` seam carries optional `stdin` and `env`, used by the hooks bridges to feed a hook command its JSON payload and `CLAUDE_*` env. This tool does **not** expose them as parameters: its request is built from `command`/`workdir`/`timeoutMs`/`signal`/`owner` only, so a model that includes `env` or `stdin` keys in its tool arguments has them ignored. This is not a trust boundary — a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), and the real defense against leaking the harness's ambient secrets is `dsh-bash-local`'s credential scrub, which works regardless. A regression guard drives the real tool with those extra args and asserts the resulting request carries neither field — its job is to catch a future refactor that blindly spreads `...args` into the request (which would silently forward model input into the post-scrub `env` merge), not to defend a wall. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
+The `BashExecRequest` seam carries optional `stdin` and `env`, used by the hooks bridges to feed a hook command its JSON payload and `CLAUDE_*` env. This tool does **not** expose them as parameters: its request is built from `command`/`workdir`/`timeoutMs`/`signal` only, so a model that includes `env` or `stdin` keys in its tool arguments has them ignored. This is not a trust boundary — a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), and the real defense against leaking the harness's ambient secrets is `dsh-bash-local`'s credential scrub, which works regardless. A regression guard drives the real tool with those extra args and asserts the resulting request carries neither field — its job is to catch a future refactor that blindly spreads `...args` into the request (which would silently forward model input into the post-scrub `env` merge), not to defend a wall. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
## Permissions
diff --git a/packages/bash/tool-bash/package.json b/packages/bash/tool-bash/package.json
index d8836d6a21..728f29a7ca 100644
--- a/packages/bash/tool-bash/package.json
+++ b/packages/bash/tool-bash/package.json
@@ -26,9 +26,13 @@
"@deepseek-ai/dsh-bash": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
+ "@deepseek-ai/dsh-tasks": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
+ "dependencies": {
+ "schemastery": "^3.18.0"
+ },
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
@@ -37,6 +41,8 @@
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
+ "@deepseek-ai/dsh-tasks": "workspace:^",
+ "@deepseek-ai/dsh-tool-tasks": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts
index d4a3105165..bb02b67b00 100644
--- a/packages/bash/tool-bash/src/index.ts
+++ b/packages/bash/tool-bash/src/index.ts
@@ -1,34 +1,22 @@
/**
- * The model-facing bash tools: `bash`, `bash_output`, `bash_kill`. Pure
- * schema + text shaping — every process concern lives behind the `ctx.bash`
- * executor seam (`@deepseek-ai/dsh-bash`), so sandbox/permission/remote
- * executor implementations swap in without touching what the model sees.
+ * The model-facing `bash` tool. Pure schema + text shaping — every process
+ * concern lives behind the `ctx.bash` executor seam (`@deepseek-ai/dsh-bash`),
+ * so sandbox/permission/remote executor implementations swap in without
+ * touching what the model sees.
*
- * Background notifications: when a background task completes, a short notice
- * is injected into the owning agent's session (`agent.inject()` — the
- * documented context seam). Injection is durable context for the NEXT model
- * request, not a wake-up: an idle agent stays idle until something sends a
- * message, which is why the tool descriptions tell the model to poll with
- * `bash_output`.
+ * Background runs are TASKS, not bash-private state: `run_in_background`
+ * starts a process through the seam and registers its handle with the generic
+ * `ctx.tasks` runtime (`@deepseek-ai/dsh-tasks`), which owns the id, the
+ * owner fence, the completion notice, and the model-facing collect/stop
+ * tools (`task_output`/`task_list`/`task_kill` from
+ * `@deepseek-ai/dsh-tool-tasks`). Whether the parameter is exposed at all is
+ * THIS plugin's `enableRunInBackground` config (default on) — the registry
+ * never rewrites a producer's schema.
*
- * Task ownership: a background task's OWNER is an opaque token — the owning
- * agent's `session.header.id` — passed to the executor at spawn
- * (`resolve({ …, owner })`) and stored ON THE TASK inside the executor
- * (`@deepseek-ai/dsh-bash`'s `ownerOf(id)` seam), NOT in a plugin-local map.
- * `bash_output`/`bash_kill` compare `ctx.bash.ownerOf(id)` to the caller's token
- * and reject a task owned by a DIFFERENT session (`owner !== undefined && owner
- * !== caller`); an unowned task (no token — started by a non-agent caller) is
- * open to anyone. Task ids are global and predictable (`bash-1`, …); under
- * multi-session ACP (RFC 011) this token check is the fence that stops one
- * session's agent from reading or killing another session's background task.
- *
- * Storing the token on the task in the EXECUTOR (disposed with the `dsh-bash`
- * fiber), rather than in this plugin, is what makes ownership survive a
- * `tool-bash` HMR reload — a reload that reset a plugin-local map would orphan
- * a task spawned before it. (The `onTaskDone` listener is still effect-scoped
- * to this plugin's `apply`, so a
- * completion landing during the reload gap still drops its one notice — the
- * pre-existing reload-gap drop — but the ownership fence itself is HMR-proof.)
+ * The tool-call abort signal is deliberately NOT wired to a background
+ * process: after the task id is returned the parent step may end while the
+ * work continues; cancellation belongs to `task_kill` and the owner-disposal
+ * cleanup. A signal already aborted before the call refuses to start.
*
* TODO(permissions): commands run with the executor's full authority. The
* permission/sandbox seam is the `tools/pre-execute` waterfall (deny/ask) plus
@@ -39,17 +27,33 @@
*/
import type { Context } from 'cordis'
+import z from 'schemastery'
import { isAbsolute, resolve as resolvePath } from 'node:path'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView, TerminalCallView, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-system-prompt'
-import { BashTaskId, OwnerToken } from '@deepseek-ai/dsh-bash'
-import type { BashRunResult, BashTask, CollectedOutput } from '@deepseek-ai/dsh-bash'
+import type {} from '@deepseek-ai/dsh-tasks'
+import type { BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
export const name = 'tool-bash'
export const inject = ['tools', 'bash', 'systemPrompt']
+/** Config: whether the model may background commands (the producer-opt-in flag). */
+export interface Config {
+ /**
+ * Expose `run_in_background` in the bash schema (default true). Disabled,
+ * the parameter is absent entirely — schema and capability never disagree.
+ * Backgrounding also needs the `ctx.tasks` runtime at call time; a missing
+ * one fails the call loud with the load-these-packages message.
+ */
+ enableRunInBackground?: boolean
+}
+
+export const Config: z = z.object({
+ enableRunInBackground: z.boolean().default(true),
+})
+
/**
* Validate the constraints the SchemaSpec can't express. `defineTool` now
* validates parsed args against the SchemaSpec before `execute` runs (the
@@ -76,18 +80,6 @@ function validateBashArgs(args: {
}
}
-/**
- * Reject an empty `task_id`. Type and presence are guaranteed by the
- * SchemaSpec validation (the arg-validation RFC); only the non-empty constraint, which the
- * DSL can't express, is left to check here.
- */
-function validateTaskId(value: string): BashTaskId {
- if (value.length === 0) {
- throw new Error(`invalid task_id: expected a string, got ${JSON.stringify(value)}`)
- }
- return BashTaskId(value)
-}
-
/** Append the truncation notice (with the full-output spill path) to a stream's text. */
function streamText(output: CollectedOutput): string {
if (!output.truncated) return output.text
@@ -131,6 +123,39 @@ export function renderResult(result: BashRunResult): string {
return body + markers.join('\n')
}
+/**
+ * Shape one background-process read into the `task_output` delta the model
+ * sees: the incremental delta, plus the lossy-read notice (with full-stream
+ * spill paths) when in-memory truncation dropped unread bytes. Empty-delta
+ * rendering (`(no new output)`) is the control surface's job, not this
+ * producer's. Exported for tests.
+ * @param read - one incremental read from the process handle.
+ * @returns the delta text with any loss notice appended.
+ */
+export function renderProcessRead(read: BashProcessRead): string {
+ if (!read.lossy) return read.delta
+ const paths = [read.stdoutSpillPath, read.stderrSpillPath].filter((p): p is string => p !== undefined)
+ const notice = `[some output was dropped from memory; full output: ${paths.length > 0 ? paths.join(', ') : '(unavailable)'}]`
+ if (read.delta.length === 0) return notice
+ return `${read.delta}${read.delta.endsWith('\n') ? '' : '\n'}${notice}`
+}
+
+/**
+ * Map a settled background process onto the generic task-outcome vocabulary:
+ * `killed` stays `killed` (detail: the signal when one is known), everything
+ * else is `completed` with the exit code as detail — a nonzero exit is
+ * REPORTED, not failed, exactly like the foreground rendering. Exported for
+ * tests.
+ * @param proc - the settled process handle.
+ * @returns the outcome for the `ctx.tasks` registration.
+ */
+export function processOutcome(proc: BashProcess): { status: 'completed' | 'killed'; detail: string } {
+ if (proc.status === 'killed') {
+ return { status: 'killed', detail: proc.signal !== null ? `signal: ${proc.signal}` : 'killed before exit' }
+ }
+ return { status: 'completed', detail: `exit code: ${proc.exitCode ?? 0}` }
+}
+
// ---------------------------------------------------------------------------
// UI presentation (tool-owned). These shape how a UI (e.g. the ACP bridge)
// renders a bash call's pending and completed states. They are display-only and
@@ -153,7 +178,7 @@ export function renderResult(result: BashRunResult): string {
* `terminal` marks the call so a capable UI renders a TERMINAL card — but ONLY a
* FOREGROUND run is a terminal: a `run_in_background` call returns a task id
* immediately (it never streams a terminal; its output is polled via
- * `bash_output`), so it is NOT marked terminal and renders as an ordinary
+ * `task_output`), so it is NOT marked terminal and renders as an ordinary
* execute card. For a foreground run the `terminal.cwd` (header) is the model
* `workdir` when given — ABSOLUTE as-is, RELATIVE for the UI bridge to resolve
* against the session cwd; when omitted the bridge fills the session workspace
@@ -253,11 +278,6 @@ function parseExitStatus(text: string): { exitCode: number } | { signal: string
return { exitCode: 0 }
}
-/** Pending-state presentation for `bash_output`/`bash_kill` (background-task tools). */
-function presentTaskCall(verb: string, args: { task_id: string }): GenericCallView {
- return { card: 'generic', title: `${verb} background task ${args.task_id}`, kind: 'execute', rawInput: args.task_id }
-}
-
/**
* Resolve the working directory for a bash call. Precedence: an explicit model
* `workdir` wins; otherwise default to the calling agent's session cwd
@@ -278,18 +298,11 @@ function resolveWorkdir(modelWorkdir: string | undefined, exec: { agent?: Agent
return modelWorkdir
}
-/** Status line for background task reads. */
-function statusLine(task: BashTask): string {
- switch (task.status) {
- case 'running': return '[status: running]'
- case 'killed': return `[status: killed${task.signal !== null ? ` by ${task.signal}` : ''}]`
- case 'completed': return `[status: completed, exit code: ${task.exitCode ?? 0}]`
- }
-}
+export function apply(ctx: Context, config: Config): void {
+ const backgroundEnabled = config.enableRunInBackground ?? true
-export function apply(ctx: Context): void {
- // The bash tools' cross-call HABIT, which the per-tool descriptions cannot
- // carry (they describe one call each): the exit-code marker is only useful
+ // The bash tool's cross-call HABIT, which the per-tool description cannot
+ // carry (it describes one call): the exit-code marker is only useful
// if the model actually checks it every time.
ctx.systemPrompt.section({
name: 'tool:bash',
@@ -297,72 +310,16 @@ export function apply(ctx: Context): void {
text: 'Check the [exit code: N] marker on every bash result; investigate failures before moving on.',
})
- /**
- * The caller's owner TOKEN — the owning agent's `session.header.id`, or
- * `undefined` for a non-agent caller. Read `session.header.id` (NOT
- * `session.id`): every other subsystem keys off the header id (the ACP bridge,
- * both persistence backends), and the sibling `resolveWorkdir` already reads
- * `session.header.cwd`, so using `session.id` here would be the asymmetry smell
- * the conventions flag. The two are equal in production, but the header is the
- * canonical identity.
- */
- const callerToken = (exec: { agent?: Agent }): OwnerToken | undefined =>
- exec.agent ? OwnerToken(exec.agent.session.header.id) : undefined
-
- /**
- * Authorize a `bash_output`/`bash_kill` call against the task's stored owner
- * token. Rejects when the task HAS an owner and it differs from the caller's
- * token — using `!== undefined` semantics, NOT truthiness, so an empty-string
- * token is still a real owner (never treated as unowned). An unowned task
- * (`ownerOf` returns `undefined`) is allowed; a truly unknown id is also
- * `undefined` here and then fails loudly at the subsequent
- * `readOutput`/`kill` ("unknown bash task"). The conservative no-agent caller
- * (`callerToken` undefined) cannot match an owned task and is rejected.
- */
- const assertTaskAccess = (taskId: BashTaskId, exec: { agent?: Agent }): void => {
- const owner = ctx.bash.ownerOf(taskId)
- if (owner !== undefined && owner !== callerToken(exec)) {
- throw new Error(`task ${taskId} belongs to another session`)
- }
- }
-
- // Background completion → inject a notice into the owning agent's session.
- // Find the live agent by its session id token via the agent registry, read
- // opportunistically with `ctx.get('agents')` (NOT `ctx.agents`/static inject):
- // this listener runs from `task.done.then` on the bash fiber — a foreign
- // fiber — where the `ctx.agents` property proxy would throw through the
- // traceable shadow; `ctx.get(name)` is the topology-independent lookup. No
- // registry mounted (`undefined`) → drop the notice. Match on
- // `agent.session.header.id`, NOT the registry key: a config agent's id differs
- // from its session id, and the owner token IS the session id.
- ctx.bash.onTaskDone((task) => {
- const ownerToken = ctx.bash.ownerOf(task.id)
- if (ownerToken === undefined) return
- const agent = ctx.get('agents')?.list().find(a => OwnerToken(a.session.header.id) === ownerToken)
- if (!agent) return
- try {
- agent.inject(
- [{ type: 'text', text: `background bash task ${task.id} finished ${statusLine(task)}. Read its output with bash_output.` }],
- { source: { kind: 'plugin', plugin: 'tool-bash' } },
- )
- } catch (error: unknown) {
- // The ONE expected failure: the agent was disposed between task
- // completion and this injection (ReactLoopAgent.inject throws
- // `agent "" is disposed`). That race is benign — drop the notice.
- // Anything else is a real bug and must surface, not be swallowed.
- if (error instanceof Error && error.message.includes('is disposed')) return
- throw error
- }
- })
-
ctx.tools.register(defineTool({
name: 'bash',
description: 'Execute a bash command (`bash -c`) and return its stdout/stderr. '
+ 'Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — '
+ 'pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. '
+ 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. '
- + 'Set `run_in_background: true` for long-running commands: the call returns a task id immediately; '
- + 'poll it with `bash_output` and stop it with `bash_kill`.',
+ + (backgroundEnabled
+ ? 'Set `run_in_background: true` for long-running commands: the call returns a task id immediately; '
+ + 'read its output with `task_output` and stop it with `task_kill`.'
+ : 'Background execution is not available; long-running commands must finish within the timeout.'),
parameters: {
command: { type: 'string', required: true, description: 'The bash command to execute.' },
description: {
@@ -374,7 +331,9 @@ export function apply(ctx: Context): void {
},
timeoutMs: { type: 'number', description: 'Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry.' },
workdir: { type: 'string', description: 'Working directory for this command. Defaults to the session workspace; a relative path is resolved against it.' },
- run_in_background: { type: 'boolean', description: 'Run in the background and return a task id immediately. No timeout applies.' },
+ ...backgroundEnabled ? {
+ run_in_background: { type: 'boolean' as const, description: 'Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies.' },
+ } : {},
},
async execute(args, exec) {
validateBashArgs(args)
@@ -389,65 +348,48 @@ export function apply(ctx: Context): void {
command: args.command,
...workdir !== undefined ? { workdir } : {},
...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {},
- ...exec.signal ? { signal: exec.signal } : {},
}
if (args.run_in_background === true) {
- // Stamp the owner token (the agent's session id) onto the spec so the
- // executor stores it on the task — the isolation fence for bash_output/
- // bash_kill. Foreground runs pass no owner (they finish inline; nothing
- // to fence).
- const task = ctx.bash.start(ctx.bash.resolve({ ...request, owner: callerToken(exec) }))
- return [{ type: 'text', text: `started background task ${task.id}` }]
+ // The generic runtime owns everything task-shaped; without it a task
+ // id would be uncollectable — fail loud with the fix, not a dangle.
+ const tasks = ctx.get('tasks')
+ if (tasks === undefined) {
+ throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
+ }
+ // A step already cancelled must not spawn; after the id is returned
+ // the tool-call signal is deliberately NOT wired to the process
+ // (cancellation belongs to task_kill / owner cleanup), so the check
+ // happens here, once, instead of passing the signal to start().
+ if (exec.signal?.aborted) throw new Error('command aborted')
+ const proc = ctx.bash.start(ctx.bash.resolve(request))
+ let id: string
+ try {
+ id = tasks.register({
+ kind: 'bash',
+ label: args.command,
+ ...exec.agent ? { owner: exec.agent } : {},
+ cancel: () => void proc.kill(),
+ done: proc.done.then(() => processOutcome(proc)),
+ readOutput: () => renderProcessRead(proc.readOutput()),
+ })
+ } catch (error: unknown) {
+ // A failed registration must not leak the just-started process: the
+ // model never received an id, so nothing could ever task_kill it.
+ // Kill, await quiescence, then fail the call with the real cause.
+ proc.kill()
+ await proc.done
+ throw error
+ }
+ return [{ type: 'text', text: `started background task ${id}` }]
}
- const result = await ctx.bash.run(ctx.bash.resolve(request))
+ const result = await ctx.bash.run(ctx.bash.resolve({
+ ...request,
+ ...exec.signal ? { signal: exec.signal } : {},
+ }))
if (result.aborted) throw new Error('command aborted')
return [{ type: 'text', text: renderResult(result) }]
},
presentCall: presentBashCall,
presentResult: presentBashResult,
}))
-
- ctx.tools.register(defineTool({
- name: 'bash_output',
- description: 'Read new output from a background bash task started with `bash` + `run_in_background`. '
- + 'Returns only output produced since the previous bash_output call, plus the task status. '
- + 'Tasks keep running while you do other work; poll again later for more output.',
- parameters: {
- task_id: { type: 'string', required: true, description: 'Task id returned by the bash tool.' },
- },
- // execute is synchronous (registry reads + string shaping) but the
- // ToolDefinition contract wants a Promise — hence resolve(), not async.
- execute(args, exec) {
- const id = validateTaskId(args.task_id)
- assertTaskAccess(id, exec)
- const read = ctx.bash.readOutput(id)
- let text = read.delta.length > 0 ? read.delta : '(no new output)'
- if (read.lossy) {
- const paths = [read.stdoutSpillPath, read.stderrSpillPath].filter((p): p is string => p !== undefined)
- const fullOutput = paths.length > 0 ? paths.join(', ') : '(unavailable)'
- text += `\n[some output was dropped from memory; full output: ${fullOutput}]`
- }
- text += `\n${statusLine(read.task)}`
- return Promise.resolve([{ type: 'text', text }])
- },
- presentCall: args => presentTaskCall('Read output from', args),
- }))
-
- ctx.tools.register(defineTool({
- name: 'bash_kill',
- description: 'Ask the executor to kill a running background bash task by task id.',
- parameters: {
- task_id: { type: 'string', required: true, description: 'Task id returned by the bash tool.' },
- },
- execute(args, exec) {
- const id = validateTaskId(args.task_id)
- assertTaskAccess(id, exec)
- const killed = ctx.bash.kill(id)
- return Promise.resolve([{
- type: 'text',
- text: killed ? `killed background task ${id}` : `task ${id} had already finished`,
- }])
- },
- presentCall: args => presentTaskCall('Kill', args),
- }))
}
diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts
index b3d6bb3f77..238176b8a6 100644
--- a/packages/bash/tool-bash/tests/integration.spec.ts
+++ b/packages/bash/tool-bash/tests/integration.spec.ts
@@ -7,15 +7,17 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
+import TaskService from '@deepseek-ai/dsh-tasks'
+import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
-import { BashTaskId } from '@deepseek-ai/dsh-bash'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
/**
* Full-loop integration: a scripted mock model drives the REAL bash tool
* through the agent loop, exercising the same seams a live model would
- * (tool/call + tool/result session events, agent.inject notifications).
+ * (tool/call + tool/result session events, the generic `ctx.tasks` runtime,
+ * agent.inject completion notices).
*/
async function harness(adapter: MockAdapter) {
const ctx = new Context()
@@ -25,6 +27,8 @@ async function harness(adapter: MockAdapter) {
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
+ await ctx.plugin(TaskService)
+ await ctx.plugin(ToolTasks)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
await ctx.plugin(ToolBash)
ctx.llm.registerAdapter(['mock'], adapter)
@@ -67,6 +71,16 @@ function resultText(event: SessionEvent): string {
.join('')
}
+/** Poll until `predicate` holds (background settlement races turn end). */
+async function pollUntil(predicate: () => boolean, timeoutMs = 5_000): Promise {
+ const deadline = Date.now() + timeoutMs
+ while (Date.now() < deadline) {
+ if (predicate()) return
+ await new Promise(resolve => setTimeout(resolve, 20))
+ }
+ throw new Error(`condition not met within ${timeoutMs}ms`)
+}
+
describe('bash tool through the agent loop', () => {
it('foreground: model calls bash, sees the result, replies', async () => {
const adapter = new MockAdapter([
@@ -116,53 +130,41 @@ describe('bash tool through the agent loop', () => {
expect(resultText(toolResult)).toContain('[exit code: 9]')
})
- it('background: start → poll → completion notice lands as context/message', async () => {
+ it('background: start ack → completion notice as context/message → task_output collects it', async () => {
+ // The task id is deterministic (a fresh TaskService counts per kind from 1),
+ // so the script can name `bash-1` without threading a generated id.
const adapter = new MockAdapter([
toolCallResponse('call-1', 'bash', { command: 'echo bg-ok', description: 'test command', run_in_background: true }),
- toolCallResponse('call-2', 'bash_output', {}, undefined),
+ textResponse('Started it in the background.'),
+ toolCallResponse('call-2', 'task_output', { task_id: 'bash-1' }),
textResponse('Background task finished.'),
])
- // The second tool call needs the REAL task id from the first result;
- // a tools/pre-execute listener rewrites the scripted arguments. (This uses
- // the low-level capability to mutate `exec` before dispatch — the
- // unadvertised mechanism behind a future first-class input-rewrite decision;
- // here it is a test shim to thread the generated id, not a product feature.)
- let taskId = ''
-
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('it-bg'), { model: 'mock' })
- // Intercept the first tool result to capture the generated task id, then
- // rewrite the second scripted call's arguments to use it.
- ctx.on('session/event', (_session, event) => {
- if (event.type === 'tool/result' && taskId === '') {
- const match = /task (bash-\d+)/.exec(resultText(event))
- if (match) taskId = match[1]!
- }
- })
- ctx.on('tools/pre-execute', async (exec, next) => {
- if (exec.name === 'bash_output') {
- exec.arguments = { task_id: taskId }
- }
- return next()
- })
-
agent.send([{ type: 'text', text: 'run echo bg-ok in the background' }])
await waitForIdle(ctx, agent)
- // Wait for the background task itself (completion may race turn end).
- const task = ctx.bash.get(BashTaskId(taskId))
- if (!task) throw new Error(`task ${taskId} not registered`)
- await task.done
+ const firstResult = findEvent(events(agent), 'tool/result')
+ expect(firstResult.data.isError).toBe(false)
+ expect(resultText(firstResult)).toBe('started background task bash-1')
- const log = events(agent)
- const firstResult = findEvent(log, 'tool/result')
- expect(resultText(firstResult)).toBe(`started background task ${taskId}`)
-
- const notice = findEvent(log, 'context/message')
+ // The task settles on its own; the tool-tasks notice listener injects a
+ // durable context/message into the owning agent's session (settlement may
+ // race turn end, so poll for it).
+ await pollUntil(() => events(agent).some(event => event.type === 'context/message'))
+ const notice = findEvent(events(agent), 'context/message')
expect(notice.data.content.some(
- block => block.type === 'text' && block.text.includes(`background bash task ${taskId} finished`),
+ block => block.type === 'text' && block.text.includes('background task bash-1 (bash: echo bg-ok) finished'),
)).toBe(true)
- expect(notice.data.source).toEqual({ kind: 'plugin', plugin: 'tool-bash' })
+ expect(notice.data.source).toEqual({ kind: 'plugin', plugin: 'tool-tasks' })
+
+ // The next turn collects the output through the generic task tool.
+ agent.send([{ type: 'text', text: 'collect it' }])
+ await waitForIdle(ctx, agent)
+ const readResult = findEvent(events(agent), 'tool/result', 'last')
+ expect(readResult.data.isError).toBe(false)
+ expect(resultText(readResult)).toContain('bg-ok')
+ expect(resultText(readResult)).toContain('[status: completed, exit code: 0]')
})
})
diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts
index 7d4b34f74f..7aebb310c8 100644
--- a/packages/bash/tool-bash/tests/tools.spec.ts
+++ b/packages/bash/tool-bash/tests/tools.spec.ts
@@ -1,21 +1,24 @@
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
-import { describe, expect, it, vi } from 'vitest'
+import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
-import { BashExecutor, BashTaskId } from '@deepseek-ai/dsh-bash'
-import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead, OwnerToken } from '@deepseek-ai/dsh-bash'
+import { BashExecutor } from '@deepseek-ai/dsh-bash'
+import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult } from '@deepseek-ai/dsh-bash'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
+import TaskService from '@deepseek-ai/dsh-tasks'
+import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
-import { renderResult } from '@deepseek-ai/dsh-tool-bash'
+import { processOutcome, renderProcessRead, renderResult } from '@deepseek-ai/dsh-tool-bash'
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-tool-bash-spec-'))
+/** Foreground-only harness: no task runtime (backgrounding fails loud here). */
async function setup() {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
@@ -27,39 +30,36 @@ async function setup() {
return ctx
}
+/** Full harness: the generic task runtime + its control surface, then the bash tool. */
+async function setupWithTasks() {
+ const ctx = new Context()
+ await ctx.plugin(SystemPrompt)
+ await ctx.plugin(ToolRegistry)
+ await ctx.plugin(AgentRegistry)
+ await ctx.plugin(TaskService)
+ await ctx.plugin(ToolTasks)
+ await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 })
+ ;(ctx.bash as LocalBashExecutor).internals = { spillDir }
+ await ctx.plugin(ToolBash)
+ return ctx
+}
+
/**
- * Build a fake {@link Agent} whose session token is `sessionId`, REGISTER it in
- * `ctx.agents` (the completion-notice path finds the owning agent by scanning
- * the registry for a matching `session.header.id`), and return it. The returned
- * agent is also passed to `execute` as `exec.agent` so it owns the spawned task.
- * The registration disposer is tracked so {@link unregisterFakeAgents} can drop
- * it (simulating the owning session disconnecting before a task completes).
+ * Build a fake {@link Agent} whose session token is `sessionId` and REGISTER it
+ * in `ctx.agents` (an owned task registration attaches the awaited owner
+ * cleanup via `ctx.agents.onCleanup`, which requires a live registered agent).
+ * The agent id is deliberately DIFFERENT from the session token so a
+ * wrong-field match fails the test.
*/
-const fakeAgentDisposers = new Map void)[]>()
-function registerFakeAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void): Agent {
- // The registry KEY (agent.id) is deliberately DIFFERENT from the session
- // token (session.header.id) — a config agent has `agentId !== sessionId`. The
- // owner token IS the session id, so the notice path must find the agent by
- // `session.header.id`, NOT the registry key. Using distinct values here makes
- // the test fail if a regression matched on the wrong field (a same-value fake
- // would pass either way — the "hits the line but not the scenario" trap).
- const agent = { id: `agent-${sessionId}`, inject, session: { header: { version: 0, id: sessionId, createdAt: 0 } } } as unknown as Agent
- const dispose = ctx.agents.register(agent)
- const list = fakeAgentDisposers.get(ctx) ?? []
- list.push(dispose)
- fakeAgentDisposers.set(ctx, list)
+function registerFakeAgent(ctx: Context, sessionId: string): Agent {
+ const agent = { id: `agent-${sessionId}`, inject: () => {}, session: { header: { version: 0, id: sessionId, createdAt: 0 } } } as unknown as Agent
+ ctx.agents.register(agent)
return agent
}
-/** Unregister every fake agent in this ctx (simulate the owning session disconnecting). */
-function unregisterFakeAgents(ctx: Context): void {
- for (const dispose of fakeAgentDisposers.get(ctx) ?? []) dispose()
- fakeAgentDisposers.delete(ctx)
-}
-
let callCounter = 0
-function call(ctx: Context, name: string, args: unknown) {
- return ctx.tools.execute({ callId: CallId(`call-${++callCounter}`), name, arguments: args })
+function call(ctx: Context, name: string, args: unknown, agent?: Agent) {
+ return ctx.tools.execute({ callId: CallId(`call-${++callCounter}`), name, arguments: args, ...agent ? { agent } : {} })
}
function text(result: { content: { type: string; text?: string }[] }): string {
@@ -83,56 +83,6 @@ async function callUntilText(
throw new Error(`${name} output did not include ${JSON.stringify(expected)}; last text was ${JSON.stringify(last !== undefined ? text(last) : '')}`)
}
-class LossyReadBashExecutor extends BashExecutor {
- private readonly task: BashTask = {
- id: BashTaskId('bash-lossy'),
- command: 'fake',
- status: 'running',
- exitCode: null,
- signal: null,
- done: Promise.resolve(),
- }
-
- resolve(request: BashExecRequest): BashExecSpec {
- return {
- command: request.command,
- workdir: request.workdir ?? process.cwd(),
- timeoutMs: request.timeoutMs ?? 0,
- ...request.signal ? { signal: request.signal } : {},
- owner: request.owner,
- }
- }
-
- run(): Promise {
- return Promise.reject(new Error('not used'))
- }
-
- start(): BashTask {
- return this.task
- }
-
- get(id: BashTaskId): BashTask | undefined {
- return id === this.task.id ? this.task : undefined
- }
-
- ownerOf(): OwnerToken | undefined {
- return undefined
- }
-
- list(): BashTask[] {
- return [this.task]
- }
-
- readOutput(id: BashTaskId): BashTaskRead {
- if (id !== this.task.id) throw new Error(`unknown bash task "${id}"`)
- return { task: this.task, delta: 'tail', lossy: true }
- }
-
- kill(): boolean {
- return false
- }
-}
-
describe('bash tool', () => {
it('returns stdout for a successful command', async () => {
const ctx = await setup()
@@ -205,7 +155,7 @@ describe('bash tool', () => {
expect(text(result)).toMatch(/ENOENT/)
})
- it('surfaces aborts as isError', async () => {
+ it('surfaces foreground aborts as isError', async () => {
const ctx = await setup()
const controller = new AbortController()
const pending = ctx.tools.execute({
@@ -220,7 +170,7 @@ describe('bash tool', () => {
expect(text(result)).toMatch(/aborted/)
})
- // Type and required-key violations are now rejected by the harness
+ // Type and required-key violations are rejected by the harness
// (defineTool validates against the SchemaSpec — the arg-validation RFC) before execute.
it.each([
[{}, /missing required property "command"/],
@@ -250,15 +200,18 @@ describe('bash tool', () => {
expect(text(result)).toMatch(pattern)
})
- it('registers all three schemas in the system prompt assembly', async () => {
+ it('registers the bash schema with run_in_background exposed by default', async () => {
const ctx = await setup()
- const names = ctx.tools.schemas().map(schema => schema.name)
- expect(names).toEqual(['bash', 'bash_output', 'bash_kill'])
- const bashSchema = ctx.tools.schemas()[0]!
+ const schemas = ctx.tools.schemas()
+ expect(schemas.map(schema => schema.name)).toEqual(['bash'])
+ const bashSchema = schemas[0]!
expect(bashSchema.parameters).toMatchObject({
type: 'object',
required: ['command', 'description'],
})
+ expect(Object.keys(bashSchema.parameters.properties as Record))
+ .toContain('run_in_background')
+ expect(bashSchema.description).toContain('task_output')
})
it('contributes the exit-code habit as its prompt section (guidance the descriptions cannot carry)', async () => {
@@ -275,7 +228,7 @@ describe('bash tool', () => {
await ctx.plugin(ToolRegistry)
await ctx.plugin(LocalBashExecutor, {})
const fiber = await ctx.plugin(ToolBash)
- expect(ctx.tools.schemas()).toHaveLength(3)
+ expect(ctx.tools.schemas()).toHaveLength(1)
expect((await ctx.systemPrompt.assemble()).sections.map(s => s.name)).toEqual(['harness:identity', 'deployment:persona', 'tool:bash'])
await fiber.dispose()
expect(ctx.tools.schemas()).toHaveLength(0)
@@ -292,348 +245,274 @@ describe('bash tool', () => {
expect(ctx.tools.schemas()).toHaveLength(0)
await ctx.plugin(LocalBashExecutor, {})
await new Promise(resolve => setTimeout(resolve, 0))
- expect(ctx.tools.schemas()).toHaveLength(3)
- })
-})
-
-describe('background tools', () => {
- it('bash with run_in_background returns a task id immediately', async () => {
- const ctx = await setup()
- const result = await call(ctx, 'bash', { command: 'sleep 0.2; echo bg-done', description: 'test command', run_in_background: true })
- expect(result.isError).toBe(false)
- expect(text(result)).toMatch(/^started background task bash-\d+$/)
+ expect(ctx.tools.schemas()).toHaveLength(1)
})
- it('bash_output polls incrementally and reports status', async () => {
- const ctx = await setup()
- const started = await call(ctx, 'bash', { command: 'echo first; sleep 1; echo second', description: 'test command', run_in_background: true })
- const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
-
- const first = await callUntilText(ctx, 'bash_output', { task_id: id }, 'first')
- expect(text(first)).toContain('first')
- expect(text(first)).toContain('[status: running]')
-
- await ctx.bash.get(id)!.done
- const second = await call(ctx, 'bash_output', { task_id: id })
- expect(text(second)).toContain('second')
- expect(text(second)).not.toContain('first')
- expect(text(second)).toContain('[status: completed, exit code: 0]')
-
- const third = await call(ctx, 'bash_output', { task_id: id })
- expect(text(third)).toContain('(no new output)')
- })
-
- it('bash_output flags lossy reads with spill paths', async () => {
+ it('applies the built-in background default when apply() receives a bare config', async () => {
+ // Bypasses the schemastery defaults on purpose: apply() must stand on its
+ // own `?? true` fallback when embedded programmatically without the schema.
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
- await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100, graceMs: 200 })
- ;(ctx.bash as LocalBashExecutor).internals = { spillDir }
- await ctx.plugin(ToolBash)
-
- const started = await call(ctx, 'bash', { command: 'for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', description: 'test command', run_in_background: true })
- const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
- await ctx.bash.get(id)!.done
- const read = await call(ctx, 'bash_output', { task_id: id })
- expect(text(read)).toContain('[some output was dropped from memory; full output: ')
- })
-
- it('bash_output reports unavailable when a lossy read has no safe spill path', async () => {
- const ctx = new Context()
- await ctx.plugin(SystemPrompt)
- await ctx.plugin(ToolRegistry)
- await ctx.plugin(LossyReadBashExecutor)
- await ctx.plugin(ToolBash)
-
- const read = await call(ctx, 'bash_output', { task_id: 'bash-lossy' })
- expect(text(read)).toBe('tail\n[some output was dropped from memory; full output: (unavailable)]\n[status: running]')
- })
-
- it('bash_kill stops a running task; repeat reports already-finished', async () => {
- const ctx = await setup()
- const started = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true })
- const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
-
- const killed = await call(ctx, 'bash_kill', { task_id: id })
- expect(text(killed)).toBe(`killed background task ${id}`)
- await ctx.bash.get(id)!.done
-
- const again = await call(ctx, 'bash_kill', { task_id: id })
- expect(text(again)).toBe(`task ${id} had already finished`)
-
- const status = await call(ctx, 'bash_output', { task_id: id })
- expect(text(status)).toContain('[status: killed by SIGTERM]')
- })
-
- it('unknown task ids are isError for both tools', async () => {
- const ctx = await setup()
- const read = await call(ctx, 'bash_output', { task_id: 'bash-999' })
- expect(read.isError).toBe(true)
- expect(text(read)).toMatch(/unknown bash task/)
- const kill = await call(ctx, 'bash_kill', { task_id: 'bash-999' })
- expect(kill.isError).toBe(true)
- })
-
- it.each([
- ['bash_output', {}, /missing required property "task_id"/],
- ['bash_output', { task_id: 9 }, /"task_id" must be a string/],
- ['bash_kill', { task_id: '' }, /invalid task_id/],
- ])('%s rejects invalid task_id %j', async (tool, args, pattern) => {
- const ctx = await setup()
- const result = await call(ctx, tool, args)
- expect(result.isError).toBe(true)
- expect(text(result)).toMatch(pattern)
- })
-
- it('injects a completion notice into the owning agent (found via the registry by session token)', async () => {
- const ctx = await setup()
- const inject = vi.fn()
- // The notice path looks the agent up in ctx.agents by its session token, so
- // the agent must be REGISTERED (not merely passed to execute). Mount a
- // registry and register a fake whose session.header.id IS the owner token.
- const agent = registerFakeAgent(ctx, 'bg', inject)
-
- const started = await ctx.tools.execute({
- callId: CallId('call-bg'),
- name: 'bash',
- arguments: { command: 'true', description: 'test command', run_in_background: true },
- agent,
- })
- const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
- await ctx.bash.get(id)!.done
-
- expect(inject).toHaveBeenCalledTimes(1)
- const [content, options] = inject.mock.calls[0] as [
- { type: string; text: string }[],
- { source: { kind: string; plugin: string } },
- ]
- expect(content[0]!.text).toContain(`background bash task ${id} finished`)
- expect(content[0]!.text).toContain('bash_output')
- expect(options.source).toEqual({ kind: 'plugin', plugin: 'tool-bash' })
- })
-
- it('swallows ONLY the disposed-agent inject error', async () => {
- const ctx = await setup()
- const agent = registerFakeAgent(ctx, 'bg', () => { throw new Error('agent "x" is disposed') })
-
- const started = await ctx.tools.execute({
- callId: CallId('call-bg2'),
- name: 'bash',
- arguments: { command: 'true', description: 'test command', run_in_background: true },
- agent,
- })
- const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
- await expect(ctx.bash.get(id)!.done).resolves.toBeUndefined()
- })
-
- it('rethrows a non-disposed inject failure (not blindly swallowed)', async () => {
- const ctx = await setup()
- // A real bug in inject (not the benign disposed race) must surface — the
- // base-class notifier contains it (logs, does not reject task.done), but
- // the listener itself must have thrown rather than silently eaten it.
- const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
- try {
- const agent = registerFakeAgent(ctx, 'bg', () => { throw new Error('unexpected inject bug') })
-
- const started = await ctx.tools.execute({
- callId: CallId('call-bg3'),
- name: 'bash',
- arguments: { command: 'true', description: 'test command', run_in_background: true },
- agent,
- })
- const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
- await ctx.bash.get(id)!.done
- // notifyTaskDone caught and logged the rethrown error.
- expect(errorSpy).toHaveBeenCalled()
- const logged = errorSpy.mock.calls.flat().some(arg => arg instanceof Error && arg.message === 'unexpected inject bug')
- expect(logged).toBe(true)
- } finally {
- errorSpy.mockRestore()
- }
- })
-
- it('drops the notice cleanly when the owning agent is gone from the registry by completion', async () => {
- // A bash task (owned by the host-scoped bash-local fiber) can OUTLIVE its
- // per-session agent — e.g. the ACP session disconnects and its AgentHandle
- // disposes while the background task is still running. The owner token is
- // still on the task, but no live agent carries it anymore, so the registry
- // lookup finds nothing and the notice is dropped (no throw).
- const ctx = await setup()
- const inject = vi.fn()
- const agent = registerFakeAgent(ctx, 'bg', inject)
- const started = await ctx.tools.execute({
- callId: CallId('call-bg4'),
- name: 'bash',
- arguments: { command: 'true', description: 'test command', run_in_background: true },
- agent,
- })
- const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
- // Unregister the agent BEFORE the task completes (simulate disconnect).
- unregisterFakeAgents(ctx)
- await expect(ctx.bash.get(id)!.done).resolves.toBeUndefined()
- expect(inject).not.toHaveBeenCalled()
- })
-
- it('does not notify when no agent owned the task', async () => {
- const ctx = await setup()
- const started = await call(ctx, 'bash', { command: 'true', description: 'test command', run_in_background: true })
- const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
- await expect(ctx.bash.get(id)!.done).resolves.toBeUndefined()
+ await ctx.plugin(LocalBashExecutor, {})
+ ToolBash.apply(ctx, {})
+ const schema = ctx.tools.schemas()[0]!
+ expect(Object.keys(schema.parameters.properties as Record))
+ .toContain('run_in_background')
})
})
-describe('background task ownership (cross-session isolation)', () => {
- /** Run a tool on behalf of a specific agent (sets exec.agent). */
- function callAs(ctx: Context, agent: import('@deepseek-ai/dsh-agent').Agent | undefined, name: string, args: unknown) {
- return ctx.tools.execute({ callId: CallId(`own-${++callCounter}`), name, arguments: args, ...agent ? { agent } : {} })
- }
- // Ownership is by TOKEN (session.header.id), NOT agent object identity — so
- // each agent needs a DISTINCT session id, else every fake yields the same
- // token and the isolation tests pass for the wrong reason (all tasks owned by
- // the same token). The impl reads `session.header.id`, so the fakes MUST carry
- // it.
- const fakeAgent = (sessionId: string) =>
- ({ inject: () => undefined, session: { header: { version: 0, id: sessionId, createdAt: 0 } } }) as unknown as import('@deepseek-ai/dsh-agent').Agent
+describe('background execution through the task runtime', () => {
+ it('run_in_background acks with the task id, readable through the REAL task_output tool', async () => {
+ const ctx = await setupWithTasks()
+ const started = await call(ctx, 'bash', { command: 'echo bg-ok', description: 'test command', run_in_background: true })
+ expect(started.isError).toBe(false)
+ expect(text(started)).toBe('started background task bash-1')
- it('rejects bash_output/bash_kill for a task owned by a DIFFERENT session token', async () => {
- const ctx = await setup()
- const a = fakeAgent('sess-a')
- const b = fakeAgent('sess-b')
- // Agent A starts a long-running background task.
- const started = await callAs(ctx, a, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true })
- const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
-
- // Agent B (a different session token) cannot read or kill A's task.
- const readByB = await callAs(ctx, b, 'bash_output', { task_id: id })
- expect(readByB.isError).toBe(true)
- expect(text(readByB)).toMatch(/belongs to another session/)
- const killByB = await callAs(ctx, b, 'bash_kill', { task_id: id })
- expect(killByB.isError).toBe(true)
- expect(text(killByB)).toMatch(/belongs to another session/)
-
- // The task is still running (B's kill did nothing) — A can still kill it.
- const killByA = await callAs(ctx, a, 'bash_kill', { task_id: id })
- expect(killByA.isError).toBe(false)
- expect(text(killByA)).toBe(`killed background task ${id}`)
+ const read = await callUntilText(ctx, 'task_output', { task_id: 'bash-1' }, 'bg-ok')
+ expect(text(read)).toContain('bg-ok')
+ // A later read reports the terminal outcome in the generic status line.
+ const final = await callUntilText(ctx, 'task_output', { task_id: 'bash-1' }, '[status: completed, exit code: 0]')
+ expect(final.isError).toBe(false)
})
- it('a DIFFERENT Agent object with the SAME session token may access the task (ownership is by token, not object identity)', async () => {
- // Ownership fences by session.header.id, NOT Agent object identity. Two
- // distinct Agent objects sharing one session token (e.g. an agent re-created
- // on the same session) are the SAME owner.
- const ctx = await setup()
- const a1 = fakeAgent('sess-shared')
- const a2 = fakeAgent('sess-shared') // distinct object, same token
- const started = await callAs(ctx, a1, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true })
- const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
- const readByA2 = await callAs(ctx, a2, 'bash_output', { task_id: id })
- expect(readByA2.isError).toBe(false)
- await callAs(ctx, a1, 'bash_kill', { task_id: id }) // cleanup
+ it('a running background task is killable through the REAL task_kill tool', async () => {
+ const ctx = await setupWithTasks()
+ await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true })
+
+ const killed = await call(ctx, 'task_kill', { task_id: 'bash-1' })
+ expect(text(killed)).toBe('requested cancellation of task bash-1')
+ // The cancel reached the process handle; the task settles as killed with
+ // the signal detail mapped by processOutcome.
+ const final = await call(ctx, 'task_output', { task_id: 'bash-1', wait: true })
+ expect(text(final)).toContain('[status: killed, signal: SIGTERM]')
})
- it('the no-agent (non-loop) caller cannot access an owned task', async () => {
- const ctx = await setup()
- const a = fakeAgent('sess-a')
- const started = await callAs(ctx, a, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true })
- const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
- // A call with no exec.agent has no token → cannot prove ownership of an owned task.
- const read = await callAs(ctx, undefined, 'bash_output', { task_id: id })
- expect(read.isError).toBe(true)
- expect(text(read)).toMatch(/belongs to another session/)
- await callAs(ctx, a, 'bash_kill', { task_id: id }) // cleanup
- })
+ it('a background task started by an agent is registered with that agent as owner', async () => {
+ // The fence SEMANTICS are pinned in dsh-tasks; this only pins that
+ // tool-bash forwards exec.agent as the registration's owner.
+ const ctx = await setupWithTasks()
+ const agent = registerFakeAgent(ctx, 'sess-owner')
+ const started = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true }, agent)
+ expect(text(started)).toBe('started background task bash-1')
- it('an UNOWNED task (started with no agent) is accessible to anyone', async () => {
- const ctx = await setup()
- // Started by a non-loop caller (no exec.agent) → no owner token recorded.
- const started = await callAs(ctx, undefined, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true })
- const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
- // Any agent (and the no-agent caller) may read/kill it.
- const read = await callAs(ctx, fakeAgent('sess-x'), 'bash_output', { task_id: id })
- expect(read.isError).toBe(false)
- const killed = await callAs(ctx, undefined, 'bash_kill', { task_id: id })
+ const anon = await call(ctx, 'task_output', { task_id: 'bash-1' })
+ expect(anon.isError).toBe(true)
+ expect(text(anon)).toMatch(/belongs to another session/)
+
+ const killed = await call(ctx, 'task_kill', { task_id: 'bash-1' }, agent)
expect(killed.isError).toBe(false)
+ await call(ctx, 'task_output', { task_id: 'bash-1', wait: true }, agent) // await settlement — no orphan
})
- it('the owner can still access its task AFTER it completes (owner token persists on the task)', async () => {
- const ctx = await setup()
- const a = fakeAgent('sess-a')
- const b = fakeAgent('sess-b')
- const started = await callAs(ctx, a, 'bash', { command: 'echo done', description: 'bg', run_in_background: true })
- const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
- await ctx.bash.get(id)!.done
- // Completion does NOT clear ownership: B is still rejected, A still allowed.
- const readByB = await callAs(ctx, b, 'bash_output', { task_id: id })
- expect(readByB.isError).toBe(true)
- expect(text(readByB)).toMatch(/belongs to another session/)
- const readByA = await callAs(ctx, a, 'bash_output', { task_id: id })
- expect(readByA.isError).toBe(false)
+ it('fails loud when the task runtime is not loaded', async () => {
+ const ctx = await setup() // no TaskService / ToolTasks
+ const result = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true })
+ expect(result.isError).toBe(true)
+ expect(text(result)).toContain('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
})
- it('ownership SURVIVES an independent tool-bash HMR reload (token lives on the executor)', async () => {
- // The owner token lives on the TASK inside the executor (dsh-bash fiber), NOT
- // in a tool-bash plugin-local map. So reloading ONLY tool-bash (executor +
- // task survive) preserves ownership. This is the regression guard: a
- // plugin-local map would make B accessible after reload, and this test would
- // catch it.
+ it('a pre-aborted call refuses to start: isError, no process spawned', async () => {
+ class CountingStartExecutor extends BashExecutor {
+ starts = 0
+ resolve(request: BashExecRequest): BashExecSpec {
+ return { command: request.command, workdir: request.workdir ?? '/x', timeoutMs: request.timeoutMs ?? 0 }
+ }
+ run(): Promise { return Promise.reject(new Error('unused')) }
+ start(spec: BashExecSpec): BashProcess {
+ this.starts += 1
+ return {
+ command: spec.command,
+ status: 'completed',
+ exitCode: 0,
+ signal: null,
+ done: Promise.resolve(),
+ readOutput: () => ({ delta: '', lossy: false }),
+ kill: () => false,
+ }
+ }
+ }
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
- await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 })
- ;(ctx.bash as LocalBashExecutor).internals = { spillDir }
- const fiber = await ctx.plugin(ToolBash)
-
- const a = fakeAgent('sess-a')
- const b = fakeAgent('sess-b')
- const started = await callAs(ctx, a, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true })
- const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
- // Before reload: B is rejected (A owns it).
- expect((await callAs(ctx, b, 'bash_output', { task_id: id })).isError).toBe(true)
-
- // Reload ONLY tool-bash; the executor and its running task (with its owner
- // token) survive.
- await fiber.dispose()
+ await ctx.plugin(AgentRegistry)
+ await ctx.plugin(TaskService)
+ await ctx.plugin(ToolTasks)
+ await ctx.plugin(CountingStartExecutor)
await ctx.plugin(ToolBash)
- expect(ctx.bash.get(id)?.status).toBe('running')
- expect(ctx.bash.ownerOf(id)).toBe('sess-a')
- // After reload, ownership is INTACT → B is STILL rejected.
- expect((await callAs(ctx, b, 'bash_output', { task_id: id })).isError).toBe(true)
- await callAs(ctx, a, 'bash_kill', { task_id: id }) // cleanup
+ const controller = new AbortController()
+ controller.abort()
+ const result = await ctx.tools.execute({
+ callId: CallId('call-pre-aborted'),
+ name: 'bash',
+ arguments: { command: 'sleep 60', description: 'test command', run_in_background: true },
+ signal: controller.signal,
+ })
+ expect(result.isError).toBe(true)
+ expect(text(result)).toContain('command aborted')
+ expect((ctx.bash as CountingStartExecutor).starts).toBe(0)
+ })
+
+ it('a failed registration kills the just-started process (no orphan without an id)', async () => {
+ class LeakProbeExecutor extends BashExecutor {
+ kills = 0
+ resolve(request: BashExecRequest): BashExecSpec {
+ return { command: request.command, workdir: request.workdir ?? '/x', timeoutMs: request.timeoutMs ?? 0 }
+ }
+
+ run(): Promise { return Promise.reject(new Error('unused')) }
+ start(spec: BashExecSpec): BashProcess {
+ let close!: () => void
+ const done = new Promise((res) => { close = res })
+ const proc: BashProcess = {
+ command: spec.command,
+ status: 'running',
+ exitCode: null,
+ signal: null,
+ done,
+ readOutput: () => ({ delta: '', lossy: false }),
+ kill: () => {
+ this.kills += 1
+ proc.status = 'killed'
+ close()
+ return true
+ },
+ }
+ return proc
+ }
+ }
+ // TaskService WITHOUT any control surface: register() throws AFTER the
+ // process already started — the producer must kill and await it.
+ const ctx = new Context()
+ await ctx.plugin(SystemPrompt)
+ await ctx.plugin(ToolRegistry)
+ await ctx.plugin(AgentRegistry)
+ await ctx.plugin(TaskService)
+ await ctx.plugin(LeakProbeExecutor)
+ await ctx.plugin(ToolBash)
+
+ const result = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true })
+ expect(result.isError).toBe(true)
+ expect(text(result)).toContain('no control surface is attached')
+ // The call resolved only after the kill landed (the catch awaits done).
+ expect((ctx.bash as LeakProbeExecutor).kills).toBe(1)
+ })
+
+ it('enableRunInBackground: false removes the parameter and flips the description', async () => {
+ const ctx = new Context()
+ await ctx.plugin(SystemPrompt)
+ await ctx.plugin(ToolRegistry)
+ await ctx.plugin(LocalBashExecutor, {})
+ await ctx.plugin(ToolBash, { enableRunInBackground: false })
+
+ const schema = ctx.tools.schemas().find(s => s.name === 'bash')!
+ expect(Object.keys(schema.parameters.properties as Record))
+ .toEqual(['command', 'description', 'timeoutMs', 'workdir'])
+ expect(schema.description).toContain('Background execution is not available')
+ expect(schema.description).not.toContain('run_in_background')
+ // The registry-held definition agrees (schema and capability never disagree).
+ const parameters = ctx.tools.get('bash')!.parameters as { properties: Record }
+ expect('run_in_background' in parameters.properties).toBe(false)
+ })
+})
+
+describe('renderProcessRead', () => {
+ const base: BashProcessRead = { delta: 'out\n', lossy: false }
+
+ it('returns the delta verbatim for a lossless read', () => {
+ expect(renderProcessRead(base)).toBe('out\n')
+ expect(renderProcessRead({ delta: '', lossy: false })).toBe('')
+ })
+
+ it('appends the loss notice with the available spill paths', () => {
+ expect(renderProcessRead({ ...base, lossy: true, stdoutSpillPath: '/spill/out.log' }))
+ .toBe('out\n[some output was dropped from memory; full output: /spill/out.log]')
+ expect(renderProcessRead({ ...base, lossy: true, stdoutSpillPath: '/spill/out.log', stderrSpillPath: '/spill/err.log' }))
+ .toBe('out\n[some output was dropped from memory; full output: /spill/out.log, /spill/err.log]')
+ })
+
+ it('reports (unavailable) when a lossy read has no safe spill path', () => {
+ expect(renderProcessRead({ ...base, lossy: true }))
+ .toBe('out\n[some output was dropped from memory; full output: (unavailable)]')
+ })
+
+ it('an empty lossy delta is the notice alone', () => {
+ expect(renderProcessRead({ delta: '', lossy: true, stderrSpillPath: '/spill/err.log' }))
+ .toBe('[some output was dropped from memory; full output: /spill/err.log]')
+ })
+
+ it('inserts the separating newline only when the delta lacks one', () => {
+ expect(renderProcessRead({ delta: 'tail', lossy: true }))
+ .toBe('tail\n[some output was dropped from memory; full output: (unavailable)]')
+ expect(renderProcessRead({ delta: 'tail\n', lossy: true }))
+ .toBe('tail\n[some output was dropped from memory; full output: (unavailable)]')
+ })
+})
+
+describe('processOutcome', () => {
+ function settled(over: Partial): BashProcess {
+ return {
+ command: 'x',
+ status: 'completed',
+ exitCode: 0,
+ signal: null,
+ done: Promise.resolve(),
+ readOutput: () => ({ delta: '', lossy: false }),
+ kill: () => false,
+ ...over,
+ }
+ }
+
+ it('maps a signal-killed process to killed with the signal detail', () => {
+ expect(processOutcome(settled({ status: 'killed', signal: 'SIGTERM' })))
+ .toEqual({ status: 'killed', detail: 'signal: SIGTERM' })
+ })
+
+ it('maps a killed process without a recorded signal (kill raced exit / spawn failure)', () => {
+ expect(processOutcome(settled({ status: 'killed', exitCode: null })))
+ .toEqual({ status: 'killed', detail: 'killed before exit' })
+ })
+
+ it('maps a completed process to its exit code', () => {
+ expect(processOutcome(settled({ exitCode: 3 })))
+ .toEqual({ status: 'completed', detail: 'exit code: 3' })
+ })
+
+ it('defensively reads a null exit code as 0 (handle shapes from other executors)', () => {
+ expect(processOutcome(settled({ exitCode: null })))
+ .toEqual({ status: 'completed', detail: 'exit code: 0' })
})
})
describe('session-cwd routing (per-session workdir)', () => {
- function callAs(ctx: Context, agent: import('@deepseek-ai/dsh-agent').Agent | undefined, args: unknown) {
- return ctx.tools.execute({ callId: CallId(`cwd-${++callCounter}`), name: 'bash', arguments: args, ...agent ? { agent } : {} })
- }
// An agent whose session header carries a cwd (what session/new records).
const agentInCwd = (cwd: string) =>
- ({ inject: () => undefined, session: { header: { version: 0, id: 'c', createdAt: 0, cwd } } }) as unknown as import('@deepseek-ai/dsh-agent').Agent
+ ({ inject: () => undefined, session: { header: { version: 0, id: 'c', createdAt: 0, cwd } } }) as unknown as Agent
it('defaults bash to the agent\'s session cwd (not the server launch dir)', async () => {
const ctx = await setup()
- const result = await callAs(ctx, agentInCwd('/tmp'), { command: 'pwd', description: 'pwd' })
+ const result = await call(ctx, 'bash', { command: 'pwd', description: 'pwd' }, agentInCwd('/tmp'))
expect(text(result).trim()).toMatch(/\/tmp$/)
})
it('an explicit absolute workdir overrides the session cwd', async () => {
const ctx = await setup()
- const result = await callAs(ctx, agentInCwd('/'), { command: 'pwd', description: 'pwd', workdir: '/tmp' })
+ const result = await call(ctx, 'bash', { command: 'pwd', description: 'pwd', workdir: '/tmp' }, agentInCwd('/'))
expect(text(result).trim()).toMatch(/\/tmp$/)
})
it('a relative workdir is resolved against the session cwd', async () => {
const ctx = await setup()
// session cwd /usr + relative 'bin' → /usr/bin
- const result = await callAs(ctx, agentInCwd('/usr'), { command: 'pwd', description: 'pwd', workdir: 'bin' })
+ const result = await call(ctx, 'bash', { command: 'pwd', description: 'pwd', workdir: 'bin' }, agentInCwd('/usr'))
expect(text(result).trim()).toMatch(/\/usr\/bin$/)
})
it('two sessions with different cwds each run bash in their own dir', async () => {
const ctx = await setup()
- const inUsr = await callAs(ctx, agentInCwd('/usr'), { command: 'pwd', description: 'pwd' })
- const inTmp = await callAs(ctx, agentInCwd('/tmp'), { command: 'pwd', description: 'pwd' })
+ const inUsr = await call(ctx, 'bash', { command: 'pwd', description: 'pwd' }, agentInCwd('/usr'))
+ const inTmp = await call(ctx, 'bash', { command: 'pwd', description: 'pwd' }, agentInCwd('/tmp'))
expect(text(inUsr).trim()).toMatch(/\/usr$/)
expect(text(inTmp).trim()).toMatch(/\/tmp$/)
})
@@ -697,35 +576,6 @@ describe('renderResult', () => {
})
})
-describe('status lines', () => {
- it('reports kills without a recorded signal (executor raced process exit)', async () => {
- const ctx = await setup()
- const started = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true })
- const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
- const task = ctx.bash.get(id)!
-
- await call(ctx, 'bash_kill', { task_id: id })
- await task.done
- // Simulate the variant where the close event carried no signal.
- task.signal = null
- const read = await call(ctx, 'bash_output', { task_id: id })
- expect(text(read)).toContain('[status: killed]')
- })
-
- it('reports completed tasks with a null exit code as exit 0', async () => {
- const ctx = await setup()
- const started = await call(ctx, 'bash', { command: 'true', description: 'test command', run_in_background: true })
- const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
- const task = ctx.bash.get(id)!
- await task.done
- // Defensive: completed tasks always carry an exit code in practice; the
- // ?? 0 fallback covers task shapes from other executor implementations.
- task.exitCode = null
- const read = await call(ctx, 'bash_output', { task_id: id })
- expect(text(read)).toContain('[status: completed, exit code: 0]')
- })
-})
-
describe('tool-owned UI presentation (presentCall / presentResult)', () => {
it('bash presentCall: a foreground run is a terminal card (command title, description, workdir → cwd absolute or relative)', async () => {
const ctx = await setup()
@@ -851,14 +701,6 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => {
})).toBeUndefined()
})
- it('bash_output / bash_kill presentCall: a readable task-scoped title, task id as rawInput', async () => {
- const ctx = await setup()
- expect(ctx.tools.get('bash_output')!.presentCall!({ task_id: 'bash-3' }))
- .toEqual({ card: 'generic', title: 'Read output from background task bash-3', kind: 'execute', rawInput: 'bash-3' })
- expect(ctx.tools.get('bash_kill')!.presentCall!({ task_id: 'bash-3' }))
- .toEqual({ card: 'generic', title: 'Kill background task bash-3', kind: 'execute', rawInput: 'bash-3' })
- })
-
it('presentCall validates softly: malformed args (missing required description) return undefined, never throw', async () => {
const ctx = await setup()
// defineTool wraps presentCall to soft-validate against the schema and fall
@@ -879,8 +721,8 @@ describe('the model-facing bash tool builds its request from named args only (no
* future refactor that blindly forwards `...args` — which would silently thread
* model input into the post-scrub `env` merge — NOT to defend a trust boundary
* (the credential scrub in dsh-bash-local is the security control; see the
- * bash-stdin-env RFC). Foreground `run()` returns a canned result; `start()` is
- * unused here.
+ * bash-stdin-env RFC). Foreground `run()` returns a canned result; `start()`
+ * hands back an already-settled fake handle so the task registration completes.
*/
class RecordingBashExecutor extends BashExecutor {
readonly requests: BashExecRequest[] = []
@@ -893,7 +735,6 @@ describe('the model-facing bash tool builds its request from named args only (no
...request.signal ? { signal: request.signal } : {},
...request.stdin !== undefined ? { stdin: request.stdin } : {},
...request.env !== undefined ? { env: request.env } : {},
- owner: request.owner,
}
}
run(): Promise {
@@ -902,12 +743,17 @@ describe('the model-facing bash tool builds its request from named args only (no
stdout: { text: 'ok', truncated: false }, stderr: { text: '', truncated: false },
})
}
- start(): BashTask { throw new Error('unused') }
- get(): BashTask | undefined { return undefined }
- ownerOf(): OwnerToken | undefined { return undefined }
- list(): BashTask[] { return [] }
- readOutput(): BashTaskRead { throw new Error('unused') }
- kill(): boolean { return false }
+ start(spec: BashExecSpec): BashProcess {
+ return {
+ command: spec.command,
+ status: 'completed',
+ exitCode: 0,
+ signal: null,
+ done: Promise.resolve(),
+ readOutput: () => ({ delta: '', lossy: false }),
+ kill: () => false,
+ }
+ }
}
async function setupRecording() {
@@ -915,6 +761,8 @@ describe('the model-facing bash tool builds its request from named args only (no
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
+ await ctx.plugin(TaskService)
+ await ctx.plugin(ToolTasks)
await ctx.plugin(RecordingBashExecutor)
await ctx.plugin(ToolBash)
return { ctx, bash: ctx.bash as RecordingBashExecutor }
@@ -947,9 +795,7 @@ describe('the model-facing bash tool builds its request from named args only (no
it('a background bash call likewise carries no env/stdin', async () => {
const { ctx, bash } = await setupRecording()
- // start() throws in this recorder, but resolve() runs first and records the
- // request — which is all this no-forward assertion needs.
- await ctx.tools.execute({
+ const result = await ctx.tools.execute({
callId: CallId('no-forward-2'),
name: 'bash',
arguments: {
@@ -960,13 +806,14 @@ describe('the model-facing bash tool builds its request from named args only (no
stdin: 'x',
},
})
+ // The call really went down the background path (the recorder sees the real
+ // request the consumer built, so the absent env/stdin below is a real
+ // negative, not a recorder that drops everything).
+ expect(text(result)).toBe('started background task bash-1')
expect(bash.requests).toHaveLength(1)
const request = bash.requests[0]!
+ expect(request.command).toBe('sleep 1')
expect('env' in request).toBe(false)
expect('stdin' in request).toBe(false)
- // The owner token IS set on a background call (the isolation fence) — proving
- // the recorder sees the real request the consumer built, so the absent
- // env/stdin above is a real negative, not a recorder that drops everything.
- expect('owner' in request).toBe(true)
})
})
diff --git a/packages/bash/tool-bash/tsconfig.json b/packages/bash/tool-bash/tsconfig.json
index 89b10bfea8..020a3e253f 100644
--- a/packages/bash/tool-bash/tsconfig.json
+++ b/packages/bash/tool-bash/tsconfig.json
@@ -14,6 +14,9 @@
{
"path": "../../../vendor/cordis"
},
+ {
+ "path": "../../../vendor/schemastery"
+ },
{
"path": "../../llm/llm"
},
@@ -25,6 +28,9 @@
},
{
"path": "../../bash/bash"
+ },
+ {
+ "path": "../../tasks/tasks"
}
]
}
diff --git a/packages/core/agent-core/README.md b/packages/core/agent-core/README.md
index 3f2ff08c0e..76fb38df31 100644
--- a/packages/core/agent-core/README.md
+++ b/packages/core/agent-core/README.md
@@ -16,7 +16,7 @@ This is the package to read to see **the whole plugin tree at once** — the tea
@deepseek-ai/dsh-tools tool registry + tools/pre-execute/post-execute
@deepseek-ai/dsh-agent agent registry + agent/* event vocabulary
@deepseek-ai/dsh-invariants dev-mode event-contract assertions
-@deepseek-ai/dsh-tool-bash the model-facing bash/bash_output/bash_kill schemas
+@deepseek-ai/dsh-tool-bash the model-facing bash schema (background runs register with ctx.tasks)
@deepseek-ai/dsh-agent-loop THE concrete loop (gets the forwarded `agents`)
(dsh-system-prompt gets the forwarded `persona`)
```
diff --git a/packages/core/agent-core/package.json b/packages/core/agent-core/package.json
index 5b1eed413a..35e4dd7015 100644
--- a/packages/core/agent-core/package.json
+++ b/packages/core/agent-core/package.json
@@ -29,7 +29,9 @@
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
+ "@deepseek-ai/dsh-tasks": "^0.0.1",
"@deepseek-ai/dsh-tool-bash": "^0.0.1",
+ "@deepseek-ai/dsh-tool-tasks": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
@@ -41,7 +43,9 @@
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
+ "@deepseek-ai/dsh-tasks": "workspace:^",
"@deepseek-ai/dsh-tool-bash": "workspace:^",
+ "@deepseek-ai/dsh-tool-tasks": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.6"
},
diff --git a/packages/core/agent-core/src/index.ts b/packages/core/agent-core/src/index.ts
index 785c8d1ff2..9a7892a8f1 100644
--- a/packages/core/agent-core/src/index.ts
+++ b/packages/core/agent-core/src/index.ts
@@ -3,7 +3,8 @@
*
* Loads the fixed set of services every harness agent needs — `timer`, the LLM
* service, the session store, system-prompt assembly, the tool registry, the
- * agent registry, the dev-mode invariants, the model-facing `bash` tool
+ * agent registry, the background task registry + its `task_*` control tools,
+ * the dev-mode invariants, the model-facing `bash` tool
* schemas, and the concrete `agent-loop` — and forwards the loop's `agents`
* list as its OWN config (default `[]`), so each app supplies its own
* pre-created agents.
@@ -50,8 +51,10 @@ import SessionStore from '@deepseek-ai/dsh-session'
import SystemPrompt, { type Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
+import TaskService from '@deepseek-ai/dsh-tasks'
import * as invariants from '@deepseek-ai/dsh-invariants'
import * as toolBash from '@deepseek-ai/dsh-tool-bash'
+import * as toolTasks from '@deepseek-ai/dsh-tool-tasks'
import AgentLoop, { type Config as AgentLoopConfig } from '@deepseek-ai/dsh-agent-loop'
export const name = 'agent-core'
@@ -103,7 +106,9 @@ export function apply(ctx: Context, config: Config): void {
})
ctx.plugin(ToolRegistry)
ctx.plugin(AgentRegistry)
+ ctx.plugin(TaskService)
ctx.plugin(invariants)
ctx.plugin(toolBash)
+ ctx.plugin(toolTasks)
ctx.plugin(AgentLoop, { agents: config.agents ?? [] })
}
diff --git a/packages/core/agent-core/tests/agent-core.spec.ts b/packages/core/agent-core/tests/agent-core.spec.ts
index 818da77311..a6b52a7ffc 100644
--- a/packages/core/agent-core/tests/agent-core.spec.ts
+++ b/packages/core/agent-core/tests/agent-core.spec.ts
@@ -81,7 +81,9 @@ describe('dsh-agent-core bundle', () => {
})
}
const assembly = await ctx.get('systemPrompt')!.assemble()
- expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha'])
+ // The rest-slot is lexicographic: the bundle's own task control tools
+ // (tool-tasks needs no executor, unlike the pending bash tool) follow alpha.
+ expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'task_kill', 'task_list', 'task_output'])
await ctx.fiber.dispose()
})
diff --git a/packages/core/agent-core/tsconfig.json b/packages/core/agent-core/tsconfig.json
index 91e5ec894e..6dee8a53e1 100644
--- a/packages/core/agent-core/tsconfig.json
+++ b/packages/core/agent-core/tsconfig.json
@@ -40,6 +40,12 @@
},
{
"path": "../../bash/tool-bash"
+ },
+ {
+ "path": "../../tasks/tasks"
+ },
+ {
+ "path": "../../tasks/tool-tasks"
}
]
}
diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md
index ed651ca7f0..bae2d9447a 100644
--- a/packages/core/agent-loop/README.md
+++ b/packages/core/agent-loop/README.md
@@ -12,7 +12,7 @@ This is the only package in the harness that contains concrete loop logic. Every
`AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface):
-- `ctx.agents.create({ agentId, sessionId, meta?, seed?, agentOptions? }): AgentHandle` — programmatic create on a caller-supplied `sessionId` (e.g. an ACP-generated id), NOT `${id}-session`; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix. Returns an [`AgentHandle`](../agent/README.md) — the owner disposes it to tear down exactly this agent (stop loop + await quiescence + unregister + remove session).
+- `ctx.agents.create({ agentId, sessionId, meta?, seed?, agentOptions? }): AgentHandle` — programmatic create on a caller-supplied `sessionId` (e.g. an ACP-generated id), NOT `${id}-session`; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix. Returns an [`AgentHandle`](../agent/README.md) — the owner disposes it to tear down exactly this agent (stop loop + await quiescence + drain the `ctx.agents.onCleanup` registrations + unregister + remove session).
- `ctx.agents.resume({ agentId, resumeSessionId, agentOptions? }): Promise` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)) and resume an agent on it. The live session id is the resumed id; turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). Returns an `AgentHandle`.
The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle) — only the programmatic factory callers (the ACP bridge and in-process subagent backends) hold a handle and own per-agent teardown.
diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts
index e7e0e562d9..8bbf6a8216 100644
--- a/packages/core/agent-loop/src/index.ts
+++ b/packages/core/agent-loop/src/index.ts
@@ -274,11 +274,15 @@ export class AgentLoop extends Service implements AgentFactory {
* chain — the runtime awaits each disposer's returned promise before the next:
*
* yield session-detach (disposed LAST — detach onAppend + remove entry)
- * yield register (disposed 2nd — unregister)
+ * yield register (disposed 3rd — unregister)
+ * yield cleanup-drain (disposed 2nd — await ctx.agents.drainCleanups)
* yield stop-and-drain (disposed FIRST — request loop stop, await agent.done)
*
* So on teardown: the loop is stopped and AWAITED to exit (its final
* `session/flush` + `turn/end` fire through the still-attached `onAppend`),
+ * THEN the awaited per-agent cleanups drain (background tasks cancel and
+ * reach quiescence while the agent is STILL registered — a settling task's
+ * completion notice can still find it, and `agent/disposed` has not fired),
* THEN the agent is unregistered, THEN the session is detached — capturing the
* closing events before detach, whether the trigger is the handle's `dispose()`
* OR a fiber unload. Rollback safety: each yield runs before the next mutation,
@@ -304,6 +308,11 @@ export class AgentLoop extends Service implements AgentFactory {
yield this.ctx.sessions.enter(session)
this.ctx.sessions.announce(session)
yield this.ctx.agents.register(agent)
+ // Disposed 2nd (after stop-and-drain below, before unregister above):
+ // drain the awaited per-agent cleanups — the AgentFactory dispose
+ // contract that lets other plugins (ctx.tasks) tie resources to this
+ // agent's quiescence. drainCleanups contains rejections itself.
+ yield async () => { await this.ctx.agents.drainCleanups(agent.id) }
// Fire AFTER register (a listener can ctx.agents.get(id) + inject()) and
// BEFORE the loop's first turn. Contained: a throwing listener is logged,
// never aborts construction (no open turn to balance here).
diff --git a/packages/core/agent-loop/tests/cleanup-drain.spec.ts b/packages/core/agent-loop/tests/cleanup-drain.spec.ts
new file mode 100644
index 0000000000..1894d73e1c
--- /dev/null
+++ b/packages/core/agent-loop/tests/cleanup-drain.spec.ts
@@ -0,0 +1,57 @@
+import { describe, expect, it, vi } from 'vitest'
+import { Context } from 'cordis'
+import { AgentId } from '@deepseek-ai/dsh-agent'
+import LlmService from '@deepseek-ai/dsh-llm'
+import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import ToolRegistry from '@deepseek-ai/dsh-tools'
+import AgentRegistry from '@deepseek-ai/dsh-agent'
+import AgentLoop from '@deepseek-ai/dsh-agent-loop'
+import { MockAdapter } from './mock-adapter.ts'
+
+async function harness() {
+ const ctx = new Context()
+ await ctx.plugin(LlmService)
+ await ctx.plugin(SessionStore)
+ await ctx.plugin(SystemPrompt)
+ await ctx.plugin(ToolRegistry)
+ await ctx.plugin(AgentRegistry)
+ await ctx.plugin(AgentLoop, { agents: [] })
+ ctx.llm.registerAdapter(['mock'], new MockAdapter([]))
+ return ctx
+}
+
+describe('agent disposal drains onCleanup registrations', () => {
+ it('awaits the cleanup after loop drain and before unregistration', async () => {
+ const ctx = await harness()
+ const handle = ctx.agents.create({ agentId: AgentId('owner'), sessionId: SessionId('owner-sess'), agentOptions: { model: 'mock' } })
+
+ const order: string[] = []
+ ctx.on('agent/disposed', () => void order.push('agent/disposed'))
+ let cleanupSettled = false
+ ctx.agents.onCleanup(handle.agent.id, async () => {
+ // The agent must STILL be registered while cleanups drain (a settling
+ // task's completion notice can still find it by session id).
+ order.push(`cleanup:registered=${ctx.agents.get(handle.agent.id) !== undefined}`)
+ await new Promise(r => setTimeout(r, 10))
+ cleanupSettled = true
+ order.push('cleanup:done')
+ })
+
+ await handle.dispose()
+ // dispose() resolves only after the cleanup settled (awaited, not fired).
+ expect(cleanupSettled).toBe(true)
+ expect(order).toEqual(['cleanup:registered=true', 'cleanup:done', 'agent/disposed'])
+ })
+
+ it('a rejecting cleanup never breaks the disposal chain', async () => {
+ const ctx = await harness()
+ const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
+ const handle = ctx.agents.create({ agentId: AgentId('owner'), sessionId: SessionId('owner-sess'), agentOptions: { model: 'mock' } })
+ ctx.agents.onCleanup(handle.agent.id, () => Promise.reject(new Error('drain boom')))
+
+ await expect(handle.dispose()).resolves.toBeUndefined()
+ expect(ctx.agents.get(handle.agent.id)).toBeUndefined()
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('drain boom'))
+ })
+})
diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md
index 9e15356ed0..01dec7deaa 100644
--- a/packages/core/agent/README.md
+++ b/packages/core/agent/README.md
@@ -20,7 +20,10 @@ Agent *creation* is provided by whichever plugin implements `AgentFactory` (phas
- `ctx.agents.create(options: CreateAgentOptions): AgentHandle` — construct, start, AND register a new agent on a caller-supplied `sessionId` (with optional `meta.cwd`/`meta.parentSession`/`meta.seedLength` and optional `seed` events for forked children). Distinct from `register` (which only records). Throws if no factory is registered.
- `ctx.agents.resume(options: ResumeAgentOptions): Promise` — load a persisted session ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)) and resume an agent on it. Async; rejects if no factory is registered, or if the factory finds session persistence unconfigured.
-`AgentHandle = { agent: Agent; dispose(): Promise }`. The disposer is a **capability** — only the holder can tear this agent down. `dispose()` stops the loop, `await`s its exit (quiescence — NOT just the `disposed` status flip), unregisters the agent, and removes its session from the store, in an order that captures the loop's final `session/flush` before the session is detached. `ctx.agents.get(id)` still returns a bare `Agent` — the handle is only for the OWNER that created it. The ACP bridge and in-process subagent backends are production consumers; config-created agents are owned by the loop fiber and never need a handle.
+`AgentHandle = { agent: Agent; dispose(): Promise }`. The disposer is a **capability** — only the holder can tear this agent down. `dispose()` stops the loop, `await`s its exit (quiescence — NOT just the `disposed` status flip), drains the registered per-agent cleanups, unregisters the agent, and removes its session from the store, in an order that captures the loop's final `session/flush` before the session is detached.
+
+- `ctx.agents.onCleanup(agentId, cleanup: () => Promise): () => void` — register an AWAITED per-agent cleanup: the agent's disposal chain runs it (after loop drain, before unregistration) and `AgentHandle.dispose()` resolves only after it settles. The seam for resources that must not outlive their owner (`ctx.tasks` background tasks) — the `agent/disposed` EMIT cannot promise that, because emit listeners are not awaited. Throws for an unregistered agent id; effect-scoped.
+- `ctx.agents.drainCleanups(agentId): Promise` — LIFECYCLE OWNERS ONLY: run and detach every registered cleanup (registration order, per-cleanup containment, loops so a cleanup registered mid-drain still runs). Part of the `AgentFactory` dispose contract — a replacement loop must call it in its disposal chain. `ctx.agents.get(id)` still returns a bare `Agent` — the handle is only for the OWNER that created it. The ACP bridge and in-process subagent backends are production consumers; config-created agents are owned by the loop fiber and never need a handle.
### Events
diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts
index 096555f925..3f173e330a 100644
--- a/packages/core/agent/src/index.ts
+++ b/packages/core/agent/src/index.ts
@@ -88,6 +88,13 @@ export interface AgentHandle {
* via {@link AgentRegistry.setFactory}. Kept on the `dsh-agent` interface so
* consumers (e.g. the ACP bridge) program against `ctx.agents` without
* depending on the concrete `dsh-agent-loop` package.
+ *
+ * Dispose contract: the handle's `dispose()` must, after draining the loop and
+ * BEFORE unregistering the agent, await
+ * {@link AgentRegistry.drainCleanups | ctx.agents.drainCleanups(agent.id)} —
+ * that is what makes {@link AgentRegistry.onCleanup} registrations an awaited
+ * quiescence guarantee for every plugin, whichever loop implementation is
+ * installed.
*/
export interface AgentFactory {
/**
@@ -117,6 +124,7 @@ const NO_FACTORY_MESSAGE = 'no agent factory registered (load an agent-loop plug
export class AgentRegistry extends Service {
private store = new Map()
private factory: AgentFactory | undefined
+ private cleanups = new Map Promise>>()
constructor(ctx: Context) {
super(ctx, 'agents')
@@ -217,6 +225,66 @@ export class AgentRegistry extends Service {
return this.store.get(id)
}
+ /**
+ * Register an AWAITED per-agent cleanup: the agent's disposal chain runs it
+ * (via {@link drainCleanups}) after the loop has drained and before the agent
+ * unregisters, and `AgentHandle.dispose()` resolves only after it settles.
+ * This is the seam for resources that must not outlive their owning agent
+ * (e.g. `ctx.tasks` background tasks) — the `agent/disposed` EMIT cannot
+ * promise that, because emit listeners are not awaited. Throws for an agent
+ * id not currently registered: a cleanup attached to a dead agent would
+ * silently never run. Effect-scoped: disposed with the calling fiber.
+ * @param agentId - the LIVE agent whose disposal must await this cleanup.
+ * @param cleanup - awaited during disposal; a rejection is logged, never propagated.
+ * @returns the disposer that detaches the cleanup without running it.
+ */
+ onCleanup(agentId: AgentId, cleanup: () => Promise): () => void {
+ const dispose = this.ctx.effect(() => {
+ if (!this.store.has(agentId)) {
+ throw new Error(`agent "${agentId}" is not registered (cleanup would never run)`)
+ }
+ let set = this.cleanups.get(agentId)
+ if (set === undefined) {
+ set = new Set()
+ this.cleanups.set(agentId, set)
+ }
+ set.add(cleanup)
+ return () => {
+ set.delete(cleanup)
+ // Guard the map removal with an identity check: after drainCleanups
+ // detached this set, the same id may map to a FRESH set (a cleanup
+ // registered mid-drain) that this stale disposer must not remove.
+ if (set.size === 0 && this.cleanups.get(agentId) === set) this.cleanups.delete(agentId)
+ }
+ }, 'agents.onCleanup()')
+ return () => void dispose()
+ }
+
+ /**
+ * Run and detach every cleanup registered for an agent (registration order,
+ * awaited sequentially, per-cleanup containment — a rejecting cleanup is
+ * logged and never starves the ones after it or the caller's disposal chain).
+ * For LIFECYCLE OWNERS ONLY: the agent factory's disposal chain calls this
+ * between loop drain and unregistration (part of the {@link AgentFactory}
+ * dispose contract); other plugins register via {@link onCleanup}, never
+ * drain. Loops until no cleanups remain, so one registered DURING the drain
+ * (from a settling task) still runs instead of leaking.
+ * @param agentId - the agent being disposed.
+ * @returns resolves when every registered cleanup has settled.
+ */
+ async drainCleanups(agentId: AgentId): Promise {
+ for (let set = this.cleanups.get(agentId); set !== undefined; set = this.cleanups.get(agentId)) {
+ this.cleanups.delete(agentId)
+ for (const cleanup of set) {
+ try {
+ await cleanup()
+ } catch (error: unknown) {
+ this.ctx.logger.warn(`agent "${agentId}": disposal cleanup threw: ${String(error)}`)
+ }
+ }
+ }
+ }
+
/**
* All live agents, in registration order.
* @returns a fresh array; mutating it does not affect the registry.
diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts
index c344cd2a6f..861b3bd3cc 100644
--- a/packages/core/agent/tests/agent.spec.ts
+++ b/packages/core/agent/tests/agent.spec.ts
@@ -1,4 +1,4 @@
-import { describe, expect, it } from 'vitest'
+import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry, { Agent, AgentId } from '@deepseek-ai/dsh-agent'
@@ -76,6 +76,129 @@ describe('AgentRegistry', () => {
})
})
+describe('AgentRegistry.onCleanup / drainCleanups', () => {
+ it('drains cleanups in registration order, awaiting each', async () => {
+ const ctx = new Context()
+ await ctx.plugin(AgentRegistry)
+ const agent = stubAgent('a1')
+ ctx.agents.register(agent)
+
+ const ran: string[] = []
+ ctx.agents.onCleanup(agent.id, async () => {
+ ran.push('first:start')
+ await new Promise(r => setTimeout(r, 10))
+ ran.push('first:end')
+ })
+ ctx.agents.onCleanup(agent.id, () => {
+ ran.push('second')
+ return Promise.resolve()
+ })
+
+ await ctx.agents.drainCleanups(agent.id)
+ // Sequential await: the second cleanup starts only after the first settled.
+ expect(ran).toEqual(['first:start', 'first:end', 'second'])
+ // Drained cleanups are detached: a second drain is a no-op.
+ await ctx.agents.drainCleanups(agent.id)
+ expect(ran).toEqual(['first:start', 'first:end', 'second'])
+ })
+
+ it('contains a rejecting cleanup: logged, later cleanups still run', async () => {
+ const ctx = new Context()
+ await ctx.plugin(AgentRegistry)
+ const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
+ const agent = stubAgent('a1')
+ ctx.agents.register(agent)
+
+ let ranAfter = false
+ ctx.agents.onCleanup(agent.id, () => Promise.reject(new Error('cleanup boom')))
+ ctx.agents.onCleanup(agent.id, () => {
+ ranAfter = true
+ return Promise.resolve()
+ })
+
+ await expect(ctx.agents.drainCleanups(agent.id)).resolves.toBeUndefined()
+ expect(ranAfter).toBe(true)
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('cleanup boom'))
+ })
+
+ it('rejects a cleanup for an agent that is not registered', async () => {
+ const ctx = new Context()
+ await ctx.plugin(AgentRegistry)
+ expect(() => ctx.agents.onCleanup(AgentId('ghost'), () => Promise.resolve()))
+ .toThrow('agent "ghost" is not registered')
+ })
+
+ it('detaches without running on disposer call and on fiber dispose (HMR safety)', async () => {
+ const ctx = new Context()
+ await ctx.plugin(AgentRegistry)
+ const agent = stubAgent('a1')
+ ctx.agents.register(agent)
+
+ let ranA = false
+ let ranB = false
+ const detach = ctx.agents.onCleanup(agent.id, () => {
+ ranA = true
+ return Promise.resolve()
+ })
+ detach()
+
+ const fiber = await ctx.plugin(Object.assign((inner: Context) => {
+ inner.agents.onCleanup(agent.id, () => {
+ ranB = true
+ return Promise.resolve()
+ })
+ }, { inject: ['agents'] }))
+ await fiber.dispose()
+
+ await ctx.agents.drainCleanups(agent.id)
+ expect(ranA).toBe(false)
+ expect(ranB).toBe(false)
+ })
+
+ it('runs a cleanup registered during the drain instead of leaking it', async () => {
+ const ctx = new Context()
+ await ctx.plugin(AgentRegistry)
+ const agent = stubAgent('a1')
+ ctx.agents.register(agent)
+
+ const ran: string[] = []
+ ctx.agents.onCleanup(agent.id, () => {
+ ran.push('outer')
+ // A settling task registering follow-up cleanup mid-drain: the drain
+ // loop must pick up the fresh set rather than strand it.
+ ctx.agents.onCleanup(agent.id, () => {
+ ran.push('mid-drain')
+ return Promise.resolve()
+ })
+ return Promise.resolve()
+ })
+
+ await ctx.agents.drainCleanups(agent.id)
+ expect(ran).toEqual(['outer', 'mid-drain'])
+ })
+
+ it('a stale disposer from a drained set does not remove a fresh registration', async () => {
+ const ctx = new Context()
+ await ctx.plugin(AgentRegistry)
+ const agent = stubAgent('a1')
+ ctx.agents.register(agent)
+
+ const detachOld = ctx.agents.onCleanup(agent.id, () => Promise.resolve())
+ await ctx.agents.drainCleanups(agent.id)
+
+ let ranFresh = false
+ ctx.agents.onCleanup(agent.id, () => {
+ ranFresh = true
+ return Promise.resolve()
+ })
+ // The old registration's disposer fires after its set was drained; the
+ // identity guard must keep it away from the fresh set under the same id.
+ detachOld()
+ await ctx.agents.drainCleanups(agent.id)
+ expect(ranFresh).toBe(true)
+ })
+})
+
describe('AgentRegistry factory seam', () => {
/** A stub AgentFactory that records calls and returns a stub agent. */
function stubFactory() {
diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts
index cacc2eef66..fcbc4d9c83 100644
--- a/packages/core/tools/tests/gen-tool-catalog.spec.ts
+++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts
@@ -35,7 +35,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
it('boots every shipped tool package and harvests its model-facing schemas', async () => {
const catalog = await collectToolCatalog()
const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort()
- expect(names).toEqual(['bash', 'bash_kill', 'bash_output', 'edit', 'read', 'subagent', 'todo_write', 'web_fetch', 'web_search', 'write'])
+ expect(names).toEqual(['bash', 'edit', 'read', 'subagent', 'task_kill', 'task_list', 'task_output', 'todo_write', 'web_fetch', 'web_search', 'write'])
// Every tool carries a JSON-Schema `parameters` object (what the model sees).
for (const entry of catalog) {
for (const schema of entry.schemas) {
diff --git a/packages/hooks/hook-protocol/tests/runner.spec.ts b/packages/hooks/hook-protocol/tests/runner.spec.ts
index 1972a39c99..1386140ae1 100644
--- a/packages/hooks/hook-protocol/tests/runner.spec.ts
+++ b/packages/hooks/hook-protocol/tests/runner.spec.ts
@@ -25,7 +25,6 @@ function recordingBash(run: (spec: BashExecSpec) => Promise): {
...request.signal ? { signal: request.signal } : {},
...request.stdin !== undefined ? { stdin: request.stdin } : {},
...request.env !== undefined ? { env: request.env } : {},
- owner: request.owner,
}
},
async run(spec: BashExecSpec): Promise {
diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md
index 8bab4c61c9..8add2250a0 100644
--- a/packages/subagent/subagent/README.md
+++ b/packages/subagent/subagent/README.md
@@ -38,6 +38,6 @@ The service also announces provider lifecycle: `subagent/provider-added` (the li
## Scope (first cut)
-The consumer collects **synchronously**: it starts a run and awaits `result`. Steering (`sendMessage`) is part of the contract but intentionally unused. Background / poll / spill semantics are deferred to a future redesign unifying long-running-tool handling across subagents and bash. See the RFC: [docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md](../../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md).
+The consumer collects **synchronously by default**: it starts a run and awaits `result`. Background delegation does not change this seam — the consumer registers the run with the generic `ctx.tasks` runtime and the run is collected through the shared task tools ([the background subagent tasks RFC](../../../docs/rfc/implemented/feature/2026-07-08-background-subagent-tasks.md)). Steering (`sendMessage`) is part of the contract but intentionally unused. See the seam RFC: [docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md](../../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md).
See `src/types.ts` for the full contracts.
diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts
index 91230b7ff4..3ac3735569 100644
--- a/packages/subagent/subagent/src/index.ts
+++ b/packages/subagent/subagent/src/index.ts
@@ -14,11 +14,12 @@
* (`@deepseek-ai/dsh-subagent-spawn`, `-fork`, `-acp`) and the model-facing
* consumer (`@deepseek-ai/dsh-tool-subagent`) are separate packages.
*
- * Scope (first cut): the consumer collects synchronously — it starts a run and
- * awaits {@link SubagentRun.result}. Steering ({@link SubagentRun.sendMessage})
- * is part of the contract but intentionally unused; background / poll / spill
- * semantics are deferred to a future redesign that unifies long-running-tool
- * handling across subagents and bash.
+ * Scope: the seam stays collection-agnostic — a run is started and its
+ * `result` awaited, whether the consumer blocks on it (foreground) or
+ * registers it as a `ctx.tasks` background task (the generic runtime owns
+ * ids/polling/stop; this seam gains nothing task-shaped). Steering
+ * ({@link SubagentRun.sendMessage}) is part of the contract but intentionally
+ * unused.
*
* The `subagent/start` / `subagent/end` lifecycle events carry an OBSERVE-ONLY
* payload; `subagent/end` additionally carries the child's `lastAssistantMessage`
@@ -26,8 +27,8 @@
* FIXME(subagent-continuation): a control-flow `subagent/end` (an awaited
* waterfall returning a stop/continue decision, like the other interception
* seams) would require reshaping this emit into a waterfall, awaiting listeners
- * before settling, and a `resume` capability on the in-process provider — part
- * of the deferred background/steering redesign, NOT this observe-only cut.
+ * before settling, and a `resume` capability on the in-process provider — a
+ * deliberate deferral, NOT part of this observe-only cut.
*
* @module @deepseek-ai/dsh-subagent
*/
diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md
index 6fe26d3083..78c502bdd3 100644
--- a/packages/subagent/tool-subagent/README.md
+++ b/packages/subagent/tool-subagent/README.md
@@ -4,7 +4,7 @@ The model-facing `subagent` tool: delegate a self-contained task to a child agen
## Provider selection is config, not model-facing
-This plugin binds to **exactly one** provider (`Config.provider`). The model sees only `{ description, prompt }` — there is no provider/type parameter in the schema. To expose more than one transport, load the plugin more than once, each bound to a different provider **and a distinct `toolName`** (the tool registry rejects a duplicate name, so a second load that kept the default `subagent` name would throw). Keeping selection in config (not the schema) is the deliberate split: the *service* holds a multi-provider registry; the *tool* picks one.
+This plugin binds to **exactly one** provider (`Config.provider`). The model sees only `{ description, prompt, run_in_background? }` — there is no provider/type parameter in the schema. To expose more than one transport, load the plugin more than once, each bound to a different provider **and a distinct `toolName`** (the tool registry rejects a duplicate name, so a second load that kept the default `subagent` name would throw). Keeping selection in config (not the schema) is the deliberate split: the *service* holds a multi-provider registry; the *tool* picks one.
## The description states the provider's context contract
@@ -14,10 +14,13 @@ The tool description and the `prompt` parameter description are DERIVED from the
|---|---|
| `provider` (required) | The `ctx.subagents` provider name to start runs on (`spawn`, `fork`, `acp`, …). |
| `toolName` | The model-facing tool name to register (default `subagent`). Set a distinct value per load when exposing multiple providers, e.g. `subagent` + `subagent_acp`. |
+| `enableRunInBackground` | Expose `run_in_background` in this instance's schema (default `true`). Disabled, the parameter is absent entirely — delegation through this instance stays strictly synchronous. |
| `agentOptions` | Default per-child `{ model? }` applied to every spawned child. (No per-child persona: the deployment persona is a context-wide section every agent shares.) |
-## Lifecycle (synchronous collect)
+## Foreground lifecycle (synchronous collect)
`execute` starts a run on the configured provider and **awaits `run.result` inside a `try/finally` that always `dispose()`s the run** — the owned child agent/session is torn down on every path (success, error, abort), never leaked. The tool's abort signal (`exec.signal`) is bridged to `run.cancel()`. A non-`completed` stop reason (aborted/error/max-tokens/refusal) maps to an `isError` tool result rather than returning partial output as success.
-Background / poll collection is deferred (see the [RFC](../../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md)); this cut blocks the parent turn until the child finishes.
+## Background delegation (a generic task)
+
+`run_in_background: true` refuses an already-aborted `exec.signal`, starts the run, registers `{ kind: 'subagent', label: description, owner: parent, cancel, done }` with `ctx.tasks` (`@deepseek-ai/dsh-tasks`), and returns `started background subagent task ` — the parent keeps working and collects/stops the child through the generic `task_output`/`task_list`/`task_kill` tools (`@deepseek-ai/dsh-tool-tasks`). The tool-call signal is deliberately NOT wired to the run after the id is returned; cancellation belongs to `task_kill` (its logged `reason` is forwarded to `run.cancel`) and the runtime's owner-disposal cleanup. The task is final-output-only (no incremental transcript — the child session remains the detailed trace), and its `done` settles only after `run.dispose()` (child quiescence), so owner disposal cannot resolve before the child is actually gone. Mapping (exported for tests): `runOutcome` — `completed` carries the final text as the task output; `aborted` → `killed`; `error`/`max-tokens`/`refusal`/unknown → `failed` with the reason as status-line detail — and `settleRun`, which disposes on both result paths and contains an infrastructure rejection as `failed`. A missing `ctx.tasks` fails the call loud (`background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`). See the [background subagent tasks RFC](../../../docs/rfc/implemented/feature/2026-07-08-background-subagent-tasks.md).
diff --git a/packages/subagent/tool-subagent/package.json b/packages/subagent/tool-subagent/package.json
index 254c9e2928..3b355d1687 100644
--- a/packages/subagent/tool-subagent/package.json
+++ b/packages/subagent/tool-subagent/package.json
@@ -25,6 +25,7 @@
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-subagent": "^0.0.1",
+ "@deepseek-ai/dsh-tasks": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
@@ -32,13 +33,15 @@
"schemastery": "^3.18.0"
},
"devDependencies": {
+ "@cordisjs/plugin-loader": "^1.0.0-rc.4",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-subagent-mock": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
+ "@deepseek-ai/dsh-tasks": "workspace:^",
+ "@deepseek-ai/dsh-tool-tasks": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
- "@cordisjs/plugin-loader": "^1.0.0-rc.4",
"cordis": "^4.0.0-rc.6"
}
}
diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts
index f48ef4345e..669621cb21 100644
--- a/packages/subagent/tool-subagent/src/index.ts
+++ b/packages/subagent/tool-subagent/src/index.ts
@@ -9,7 +9,7 @@
* EXACTLY ONE provider name (`Config.provider`). To expose more than one
* transport, load the plugin more than once, each bound to a different provider
* — there is no provider/type parameter in the model-facing schema. The model
- * sees only `{ description, prompt }`.
+ * sees only `{ description, prompt }` (plus `run_in_background` when enabled).
*
* The tool DESCRIPTION is derived from the bound provider's context contract
* ({@link providerWording}): a fresh-context provider (spawn, ACP) gets the
@@ -20,13 +20,24 @@
* provider goes away — so no load-order requirement exists and an HMR reload
* of the backend re-derives the wording from the fresh provider.
*
- * Collection is SYNCHRONOUS this cut: `execute` starts a run and awaits
+ * FOREGROUND collection is synchronous: `execute` starts a run and awaits
* `run.result` inside a `try/finally` that always disposes the run, so the
* owned child agent/session is torn down on every path (success, error, abort)
* and never leaks as a live idle child. A non-`completed` stop reason maps to an
* `isError` tool result (by throwing) rather than returning partial output as
* success.
*
+ * BACKGROUND delegation (`run_in_background: true`, exposed only when this
+ * instance's `enableRunInBackground` config allows) is a generic background
+ * TASK: the run is registered with `ctx.tasks` (kind `subagent`, final-output
+ * only — the child session remains the detailed trace) and collected/stopped
+ * through the generic `task_output`/`task_list`/`task_kill` tools. The
+ * tool-call abort signal is deliberately NOT wired to a background child:
+ * after the id is returned the parent step may end while the child works —
+ * cancellation belongs to `task_kill` and the owner-disposal cleanup. The
+ * task's `done` settles only after `run.dispose()` (child quiescence), which
+ * is what makes owner-disposal cleanup an actual no-leak guarantee.
+ *
* @module @deepseek-ai/dsh-tool-subagent
*/
@@ -36,6 +47,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
import type { AgentOptions } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SubagentProvider, SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
+import type { TaskOutcome } from '@deepseek-ai/dsh-tasks'
export const name = 'tool-subagent'
export const inject = ['tools', 'subagents']
@@ -52,6 +64,14 @@ export interface Config {
* `{ provider: 'acp', toolName: 'subagent_acp' }`.
*/
toolName?: string
+ /**
+ * Expose `run_in_background` in this instance's schema (default true).
+ * Disabled, the parameter is absent entirely — schema and capability never
+ * disagree; delegation through this instance stays strictly synchronous.
+ * Backgrounding also needs the `ctx.tasks` runtime at call time; a missing
+ * one fails the call loud with the load-these-packages message.
+ */
+ enableRunInBackground?: boolean
/**
* Default per-child agent options (model) applied to every spawned child.
* Omitted fields fall back to the child loop's own defaults. There is no
@@ -64,6 +84,7 @@ export interface Config {
export const Config: z = z.object({
provider: z.string().required(),
toolName: z.string().default('subagent'),
+ enableRunInBackground: z.boolean().default(true),
agentOptions: z.object({
model: z.string(),
}),
@@ -102,6 +123,54 @@ function stopReasonError(result: SubagentResult): string | undefined {
}
}
+/**
+ * Map a settled subagent result onto the generic task-outcome vocabulary:
+ * `completed` carries the final text as the task's idempotent output;
+ * `aborted` is the task-level `killed`; everything else — `error`,
+ * `max-tokens`, `refusal`, and unknown merge-extensible reasons — is `failed`
+ * with the reason as the status-line detail (partial output is NOT reported
+ * as output, mirroring the synchronous path's report-the-reason rule).
+ * Exported for tests.
+ * @param result - the child's terminal result.
+ * @returns the outcome for the `ctx.tasks` registration.
+ */
+export function runOutcome(result: SubagentResult): TaskOutcome { switch (result.stopReason) {
+ case 'completed':
+ return { status: 'completed', output: outputText(result.output) }
+ case 'aborted':
+ return { status: 'killed' }
+ case 'error':
+ case 'max-tokens':
+ case 'refusal':
+ return { status: 'failed', detail: result.stopReason }
+ // Merge-extensible union: an unknown terminal reason is a failure with
+ // the raw reason as detail, never partial output as success.
+ default:
+ return { status: 'failed', detail: String(result.stopReason) }
+}
+}
+
+/**
+ * Settle a background run at QUIESCENCE: await the child's result, ALWAYS
+ * dispose the run (the owned child agent/session is released on every path),
+ * and only then report the mapped outcome — so the task registry's `done`,
+ * and therefore owner-disposal cleanup, cannot resolve before the child is
+ * actually gone. A rejected `run.result` (infrastructure fault — no
+ * SubagentResult exists) reports `failed` with the error as detail rather
+ * than rejecting the producer contract. Exported for tests.
+ * @param run - the live background run to settle and release.
+ * @returns the task outcome, after the run's resources are released.
+ */
+export async function settleRun(run: SubagentRun): Promise {
+ try {
+ return runOutcome(await run.result)
+ } catch (error: unknown) {
+ return { status: 'failed', detail: String(error) }
+ } finally {
+ await run.dispose()
+ }
+}
+
/**
* Model-facing wording per context contract ({@link SubagentProvider.inheritsParentContext}).
* A fresh child needs a standalone prompt; a forked child already sees the
@@ -150,9 +219,12 @@ export function apply(ctx: Context, config: Config): void {
let disposeTool: (() => void) | undefined
const mount = (provider: SubagentProvider): void => {
const wording = providerWording(provider.inheritsParentContext)
+ const backgroundEnabled = config.enableRunInBackground !== false
disposeTool = ctx.tools.register(defineTool({
name: config.toolName ?? 'subagent',
- description: wording.description,
+ description: wording.description + (backgroundEnabled
+ ? ' Set `run_in_background: true` to get a task id immediately and keep working; collect the final answer with `task_output` (wait: true when you are blocked on it) and stop it with `task_kill`.'
+ : ''),
parameters: {
description: {
type: 'string',
@@ -164,6 +236,12 @@ export function apply(ctx: Context, config: Config): void {
required: true,
description: wording.promptDescription,
},
+ ...backgroundEnabled ? {
+ run_in_background: {
+ type: 'boolean' as const,
+ description: 'Run the subagent as a background task and return a task id immediately (collect with task_output, stop with task_kill).',
+ },
+ } : {},
},
async execute(args, exec): Promise {
const parent = exec.agent
@@ -174,6 +252,47 @@ export function apply(ctx: Context, config: Config): void {
throw new Error('subagent tool requires a calling agent (exec.agent was undefined)')
}
+ if (args.run_in_background === true) {
+ // The generic runtime owns everything task-shaped; without it a task
+ // id would be uncollectable — fail loud with the fix, not a dangle.
+ const tasks = ctx.get('tasks')
+ if (tasks === undefined) {
+ throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
+ }
+ // A step already cancelled must not spawn a child. After the id is
+ // returned the tool-call signal is deliberately NOT wired to the run
+ // (the child outlives this step; cancellation belongs to task_kill
+ // and owner-disposal cleanup), so the request carries NO signal.
+ if (exec.signal?.aborted) throw new Error('subagent delegation aborted')
+ const run = ctx.subagents.start(config.provider, {
+ prompt: [{ type: 'text', text: args.prompt }],
+ parent,
+ ...config.agentOptions ? { agentOptions: config.agentOptions } : {},
+ })
+ const done = settleRun(run)
+ let id: string
+ try {
+ id = tasks.register({
+ kind: 'subagent',
+ label: args.description,
+ owner: parent,
+ cancel: (reason) => { run.cancel(reason ?? 'background subagent task killed') },
+ done,
+ // No readOutput: a subagent task is final-output-only — the child
+ // session remains the detailed trace.
+ })
+ } catch (error: unknown) {
+ // A failed registration must not leak the just-started child: the
+ // model never received an id, so nothing could ever task_kill it.
+ // Cancel, await `done` (which settles only after run.dispose() —
+ // child quiescence), then fail the call with the real cause.
+ run.cancel('background task registration failed')
+ await done
+ throw error
+ }
+ return [{ type: 'text', text: `started background subagent task ${id}` }]
+ }
+
const request: SubagentStartRequest = {
prompt: [{ type: 'text', text: args.prompt }],
parent,
diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts
index 611069ef76..06d39c2ec6 100644
--- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts
+++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts
@@ -5,9 +5,13 @@ import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
+import AgentRegistry from '@deepseek-ai/dsh-agent'
import SubagentService from '@deepseek-ai/dsh-subagent'
+import TaskService from '@deepseek-ai/dsh-tasks'
+import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import * as mock from '@deepseek-ai/dsh-subagent-mock'
import * as tool from '../src/index.ts'
+import { runOutcome, settleRun } from '../src/index.ts'
/**
* Drives the REAL plugin body: mounts `dsh-tool-subagent` on a real
@@ -60,12 +64,21 @@ describe('dsh-tool-subagent', () => {
expect(text(result)).toBe('child says hi')
})
- it('exposes only description + prompt to the model (no provider/type parameter)', async () => {
+ it('exposes description + prompt + run_in_background to the model (no provider/type parameter)', async () => {
const ctx = await setup({ provider: 'mock' })
const schema = ctx.tools.schemas().find(s => s.name === 'subagent')
expect(schema).toBeDefined()
const props = (schema!.parameters as { properties?: Record }).properties ?? {}
+ expect(Object.keys(props).sort()).toEqual(['description', 'prompt', 'run_in_background'])
+ expect(schema!.description).toContain('task_output')
+ })
+
+ it('omits run_in_background entirely when the instance disables it (schema and capability never disagree)', async () => {
+ const ctx = await setup({ provider: 'mock', enableRunInBackground: false })
+ const schema = ctx.tools.schemas().find(s => s.name === 'subagent')
+ const props = (schema!.parameters as { properties?: Record }).properties ?? {}
expect(Object.keys(props).sort()).toEqual(['description', 'prompt'])
+ expect(schema!.description).not.toContain('task_output')
})
it.each([
@@ -452,3 +465,180 @@ describe('dsh-tool-subagent', () => {
expect(unwrapped.Config).toBeDefined()
})
})
+
+describe('dsh-tool-subagent background mode', () => {
+ /** A parent agent carrying a real session token, registered in ctx.agents (owner-cleanup wiring requires a live registry entry). */
+ function ownerAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void = () => {}): Agent {
+ const agent = { id: AgentId(`agent-${sessionId}`), inject, session: { header: { version: 0, id: sessionId, createdAt: 0 } } } as unknown as Agent
+ ctx.agents.register(agent)
+ return agent
+ }
+
+ async function backgroundSetup(toolConfig: tool.Config, mockConfig: Partial = {}) {
+ const ctx = await setup(toolConfig, mockConfig)
+ await ctx.plugin(AgentRegistry)
+ await ctx.plugin(TaskService)
+ await ctx.plugin(ToolTasks, {})
+ return ctx
+ }
+
+ it('returns a task id immediately and the answer is collected through task_output', async () => {
+ const ctx = await backgroundSetup({ provider: 'mock', agentOptions: { model: 'child-model' } }, { reply: 'background answer' })
+ const parent = ownerAgent(ctx, 'sess-parent')
+
+ const start = await callSubagent(ctx, { description: 'deep research', prompt: 'dig in', run_in_background: true }, { agent: parent })
+ expect(start.isError).toBe(false)
+ expect(text(start)).toBe('started background subagent task subagent-1')
+
+ const collected = await ctx.tools.execute({
+ callId: CallId('collect-1'),
+ name: 'task_output',
+ arguments: { task_id: 'subagent-1', wait: true },
+ agent: parent,
+ })
+ expect(text(collected)).toBe('background answer\n[status: completed]')
+
+ // Final-output reads are idempotent (not consumed).
+ const again = await ctx.tools.execute({
+ callId: CallId('collect-2'),
+ name: 'task_output',
+ arguments: { task_id: 'subagent-1' },
+ agent: parent,
+ })
+ expect(text(again)).toBe('background answer\n[status: completed]')
+ })
+
+ it('fails loud when the tasks runtime is not loaded', async () => {
+ const ctx = await setup({ provider: 'mock' })
+ const result = await callSubagent(ctx, { description: 'd', prompt: 'p', run_in_background: true })
+ expect(result.isError).toBe(true)
+ expect(text(result)).toContain('background tasks unavailable: load @deepseek-ai/dsh-tasks')
+ })
+
+ it('refuses to start when the tool signal is already aborted', async () => {
+ const ctx = await backgroundSetup({ provider: 'mock' })
+ const parent = ownerAgent(ctx, 'sess-parent')
+ const controller = new AbortController()
+ controller.abort()
+ const result = await callSubagent(ctx, { description: 'd', prompt: 'p', run_in_background: true }, { agent: parent, signal: controller.signal })
+ expect(result.isError).toBe(true)
+ expect(text(result)).toContain('subagent delegation aborted')
+ })
+
+ it('forwards task_kill reasons to run.cancel (and defaults one when absent)', async () => {
+ // A provider whose runs settle only on cancel — the mock settles on a
+ // microtask, too fast to observe a LIVE kill through the real tools.
+ const ctx = await backgroundSetup({ provider: 'mock', agentOptions: { model: 'child-model' } })
+ const parent = ownerAgent(ctx, 'sess-parent')
+ const cancels: (string | undefined)[] = []
+ ctx.subagents.registerProvider({
+ name: 'hanging',
+ capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
+ inheritsParentContext: false,
+ start: () => {
+ let settle!: (value: { output: { type: 'text'; text: string }[]; stopReason: 'aborted' }) => void
+ return {
+ id: AgentId(`hang-${cancels.length}`),
+ result: new Promise((res) => { settle = res }),
+ cancel(reason?: string) { cancels.push(reason); settle({ output: [], stopReason: 'aborted' }) },
+ dispose: () => Promise.resolve(),
+ }
+ },
+ })
+ // Direct apply (schema bypass): schemastery would default agentOptions to
+ // an (truthy) empty object — the raw config exercises the omitted branch
+ // on the background start request.
+ tool.apply(ctx, { provider: 'hanging', toolName: 'subagent_hang' })
+
+ const startOne = await ctx.tools.execute({ callId: CallId('h1'), name: 'subagent_hang', arguments: { description: 'one', prompt: 'p', run_in_background: true }, agent: parent })
+ const startTwo = await ctx.tools.execute({ callId: CallId('h2'), name: 'subagent_hang', arguments: { description: 'two', prompt: 'p', run_in_background: true }, agent: parent })
+ expect(text(startOne)).toBe('started background subagent task subagent-1')
+ expect(text(startTwo)).toBe('started background subagent task subagent-2')
+
+ const withReason = await ctx.tools.execute({ callId: CallId('k1'), name: 'task_kill', arguments: { task_id: 'subagent-1', reason: 'superseded' }, agent: parent })
+ const withoutReason = await ctx.tools.execute({ callId: CallId('k2'), name: 'task_kill', arguments: { task_id: 'subagent-2' }, agent: parent })
+ expect(text(withReason)).toBe('requested cancellation of task subagent-1')
+ expect(text(withoutReason)).toBe('requested cancellation of task subagent-2')
+ expect(cancels).toEqual(['superseded', 'background subagent task killed'])
+
+ // The aborted children settle as killed tasks.
+ const killed = await ctx.tools.execute({ callId: CallId('w1'), name: 'task_output', arguments: { task_id: 'subagent-1', wait: true }, agent: parent })
+ expect(text(killed)).toBe('(no new output)\n[status: killed]')
+ })
+
+ it('runOutcome maps the stop-reason vocabulary onto task outcomes', () => {
+ const output = [{ type: 'text' as const, text: 'partial' }]
+ expect(runOutcome({ output, stopReason: 'completed' })).toEqual({ status: 'completed', output: 'partial' })
+ expect(runOutcome({ output, stopReason: 'aborted' })).toEqual({ status: 'killed' })
+ expect(runOutcome({ output, stopReason: 'error' })).toEqual({ status: 'failed', detail: 'error' })
+ expect(runOutcome({ output, stopReason: 'max-tokens' })).toEqual({ status: 'failed', detail: 'max-tokens' })
+ expect(runOutcome({ output, stopReason: 'refusal' })).toEqual({ status: 'failed', detail: 'refusal' })
+ // Merge-extensible: an unknown reason is failed-with-detail, never success.
+ expect(runOutcome({ output, stopReason: 'paused' as never })).toEqual({ status: 'failed', detail: 'paused' })
+ })
+
+ it('settleRun disposes the run before reporting, on both result paths', async () => {
+ const order: string[] = []
+ const completed = await settleRun({
+ id: AgentId('child-1'),
+ result: Promise.resolve({ output: [{ type: 'text' as const, text: 'ok' }], stopReason: 'completed' as const }),
+ cancel() {},
+ dispose() { order.push('dispose'); return Promise.resolve() },
+ })
+ order.push('reported')
+ expect(completed).toEqual({ status: 'completed', output: 'ok' })
+ expect(order).toEqual(['dispose', 'reported'])
+
+ // An infrastructure rejection still disposes and reports failed.
+ let disposed = false
+ const failed = await settleRun({
+ id: AgentId('child-2'),
+ result: Promise.reject(new Error('transport gone')),
+ cancel() {},
+ dispose() { disposed = true; return Promise.resolve() },
+ })
+ expect(failed).toEqual({ status: 'failed', detail: 'Error: transport gone' })
+ expect(disposed).toBe(true)
+ })
+})
+
+describe('background registration failure (no orphaned child)', () => {
+ it('cancels and disposes the just-started run when register() throws', async () => {
+ // TaskService is loaded but NO control surface is attached, so
+ // ctx.tasks.register throws AFTER the provider run already started.
+ const ctx = await setup({ provider: 'mock' })
+ await ctx.plugin(AgentRegistry)
+ await ctx.plugin(TaskService)
+ const parent = { id: AgentId('agent-sess-p'), inject: () => {}, session: { header: { version: 0, id: 'sess-p', createdAt: 0 } } } as unknown as Agent
+ ctx.agents.register(parent)
+
+ const events: string[] = []
+ ctx.subagents.registerProvider({
+ name: 'probe',
+ capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
+ inheritsParentContext: false,
+ start: () => {
+ let settle!: (value: { output: never[]; stopReason: 'aborted' }) => void
+ return {
+ id: AgentId('probe-child'),
+ result: new Promise((res) => { settle = res }),
+ cancel(reason?: string) { events.push(`cancel:${reason}`); settle({ output: [], stopReason: 'aborted' }) },
+ dispose() { events.push('dispose'); return Promise.resolve() },
+ }
+ },
+ })
+ tool.apply(ctx, { provider: 'probe', toolName: 'subagent_probe' })
+
+ const result = await ctx.tools.execute({
+ callId: CallId('probe-1'),
+ name: 'subagent_probe',
+ arguments: { description: 'd', prompt: 'p', run_in_background: true },
+ agent: parent,
+ })
+ expect(result.isError).toBe(true)
+ expect(text(result)).toContain('no control surface is attached')
+ // The child was cancelled AND disposed before the call settled — the
+ // model never got an id, so nothing else could ever collect or kill it.
+ expect(events).toEqual(['cancel:background task registration failed', 'dispose'])
+ })
+})
diff --git a/packages/subagent/tool-subagent/tsconfig.json b/packages/subagent/tool-subagent/tsconfig.json
index 0a10bce7c5..2aa9d4f14e 100644
--- a/packages/subagent/tool-subagent/tsconfig.json
+++ b/packages/subagent/tool-subagent/tsconfig.json
@@ -28,6 +28,9 @@
},
{
"path": "../subagent"
+ },
+ {
+ "path": "../../tasks/tasks"
}
]
}
diff --git a/packages/tasks/README.md b/packages/tasks/README.md
new file mode 100644
index 0000000000..a22528ba6d
--- /dev/null
+++ b/packages/tasks/README.md
@@ -0,0 +1,10 @@
+# tasks/ — background task capability family
+
+The shared background-task runtime: ONE home for task ids, owner isolation, polling, cancellation, wait, and completion notification, so bash, subagents, and every future long-running tool expose the same model-facing habit instead of cloning a private task protocol each. Rationale and the full design: [the background-task-runtime RFC](../../docs/rfc/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md).
+
+| Package | ctx key | Role |
+|---|---|---|
+| [`tasks`](tasks/README.md) (`@deepseek-ai/dsh-tasks`) | `ctx.tasks` | The registry service: branded `-N` ids, owner-fenced read/kill/wait/list, settlement bookkeeping, the awaited owner-cleanup path, and the `attachSurface` misconfiguration fence |
+| [`tool-tasks`](tool-tasks/README.md) (`@deepseek-ai/dsh-tool-tasks`) | — | The model-facing control surface: `task_output`, `task_list`, `task_kill`, the completion-notice injection, and the background-habit prompt section |
+
+The split is the state/surface boundary: the registry holds task state (an HMR reload of any tool plugin never orphans or kills a running task), while the tool surface is stateless presentation. Producers (`dsh-tool-bash`, `dsh-tool-subagent`) register running work via `ctx.tasks.register` and keep their own execution concerns; whether a producer exposes `run_in_background` is that producer's own `enableRunInBackground` config, never rewritten by this family.
diff --git a/packages/tasks/tasks/README.md b/packages/tasks/tasks/README.md
new file mode 100644
index 0000000000..ec5a82f52c
--- /dev/null
+++ b/packages/tasks/tasks/README.md
@@ -0,0 +1,25 @@
+# @deepseek-ai/dsh-tasks
+
+The background task registry (`ctx.tasks`): a runtime-global, CONCRETE service (no interface/implementation split — one sensible in-process implementation exists; a durable job backend would own that extraction) that gives every long-running tool the same ids, isolation, and lifecycle.
+
+## Service API
+
+- `register(registration): TaskId` — a producer hands over running work: `kind` (also the id prefix), `label`, optional `owner: Agent`, `cancel(reason?)`, `done: Promise` (settles at QUIESCENCE, never rejects), optional `readOutput()` (stream kinds; absence = final-output-only). Throws while no control surface is attached — the loud fence against a deployment exposing `run_in_background` with no way to collect or stop the work — and is ATOMIC: a failed registration mutates nothing (no stored task, no counter bump, no owner-cleanup bookkeeping), so producers can reliably cancel their just-started work and rethrow.
+- `get(id, caller?)` / `list(caller?)` — non-consuming snapshots; `list` returns only caller-owned plus unowned tasks (a global listing would leak foreign labels).
+- `read(id, caller?): TaskRead` — stream kinds consume the per-task cursor (v1's single intended reader is the owning model — a non-consuming multi-reader surface would be a cursor/snapshot API extension, not a `read` change); final kinds read the terminal output idempotently.
+- `kill(id, caller?, reason?)` — `'requested'` (live task: producer `cancel` runs first — a throw fails the kill loud and leaves the task untouched — then `stopping`) or `'already-terminal'`. Every successful kill marks the task `reported` (the killer saw the end → completion notice suppressed).
+- `wait(id, timeoutMs, caller?, signal?)` — resolves with the terminal snapshot (marked `reported`), or the live snapshot at timeout; an aborted signal rejects the WAIT only.
+- `onTaskDone(listener)` — exactly once per task with the terminal snapshot; effect-scoped, per-listener containment, silent after service disposal.
+- `attachSurface(name)` — declares a control surface exists (the model tools, or a deployment's custom surface); effect-scoped.
+
+Every read/kill/wait/get compares the task's owner session (`owner.session.header.id`) with the caller's and rejects a foreign one — ids are predictable (`bash-1`), so the fence, not id secrecy, is the isolation boundary.
+
+## Lifecycle
+
+- Registrations are NOT effect-scoped to the registering fiber: tasks belong to their owning agent + producing backend, so producer/surface HMR reloads never touch them.
+- An owned task attaches (once per owner) an awaited cleanup via `ctx.agents.onCleanup`: on the owner's disposal the registry cancels its live tasks, awaits each `done`, and drops the snapshots — `AgentHandle.dispose()` resolves only after quiescence.
+- Service disposal closes the listener registry first (late teardown kills stay silent), cancels every live task with containment, and awaits settlement.
+
+## Non-goals (v1)
+
+Durable/cross-restart tasks, non-consuming observation cursors, and foreground→background promotion are deliberate deferrals — see the [runtime RFC](../../../docs/rfc/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) § Alternatives.
diff --git a/packages/tasks/tasks/package.json b/packages/tasks/tasks/package.json
new file mode 100644
index 0000000000..3b2b9532c2
--- /dev/null
+++ b/packages/tasks/tasks/package.json
@@ -0,0 +1,35 @@
+{
+ "name": "@deepseek-ai/dsh-tasks",
+ "description": "Background task registry (ctx.tasks) for the DeepSeek Harness — shared ids, owner isolation, polling, cancellation, and completion listeners for long-running tool work",
+ "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"
+ },
+ "./src/*": "./src/*",
+ "./package.json": "./package.json"
+ },
+ "files": [
+ "lib/index.js",
+ "lib/types/**/*.d.ts",
+ "lib/types/**/*.d.ts.map",
+ "src"
+ ],
+ "license": "BSD-3-Clause",
+ "peerDependencies": {
+ "@deepseek-ai/dsh-agent": "^0.0.1",
+ "@deepseek-ai/dsh-brand": "^0.0.1",
+ "cordis": "^4.0.0-rc.6"
+ },
+ "devDependencies": {
+ "@deepseek-ai/dsh-agent": "workspace:^",
+ "@deepseek-ai/dsh-brand": "workspace:^",
+ "@deepseek-ai/dsh-session": "workspace:^",
+ "cordis": "^4.0.0-rc.6"
+ }
+}
diff --git a/packages/tasks/tasks/src/index.ts b/packages/tasks/tasks/src/index.ts
new file mode 100644
index 0000000000..ac9821b849
--- /dev/null
+++ b/packages/tasks/tasks/src/index.ts
@@ -0,0 +1,457 @@
+/**
+ * The background task registry (`ctx.tasks`): ONE home for the semantics every
+ * long-running tool needs — branded task ids, owner-scoped isolation, status
+ * snapshots, incremental/final output reads, cancellation, wait-for-terminal,
+ * completion listeners, and the awaited owner-cleanup path. Producers
+ * (`dsh-tool-bash` background commands, `dsh-tool-subagent` background
+ * delegations, future long-running tools) register running work via
+ * {@link TaskService.register} and keep their own execution concerns; the
+ * model-facing control surface (`@deepseek-ai/dsh-tool-tasks`) drives the
+ * generic read/list/kill/wait operations.
+ *
+ * A CONCRETE service, not an interface/implementation seam pair: there is one
+ * sensible in-process implementation today, and the capability-seam convention
+ * says not to split preemptively (see the background-task-runtime RFC).
+ *
+ * Cross-session isolation lives IN the registry: task ids are runtime-global
+ * and predictable (`bash-1`, `subagent-1`), so every read/kill/wait compares
+ * the task's owner session against the caller and rejects a foreign one —
+ * every surface gets the fence for free instead of re-implementing it.
+ *
+ * Task registrations are NOT effect-scoped to the registering fiber: a task
+ * belongs to its owning agent and producing backend, not to the tool plugin
+ * whose call started it, so an HMR reload of a producer or of the control
+ * surface never orphans or kills a running task. The registry's own disposal
+ * cancels every live task and awaits settlement — no orphans survive
+ * `fiber.dispose()`.
+ *
+ * @module @deepseek-ai/dsh-tasks
+ */
+
+import { Context, Service } from 'cordis'
+import type { Agent, AgentId } from '@deepseek-ai/dsh-agent'
+import { TaskId } from './types.ts'
+import type { TaskDoneListener, TaskOutcome, TaskRead, TaskRegistration, TaskSnapshot, TaskStatus } from './types.ts'
+
+export { TaskId } from './types.ts'
+export type {
+ TaskDoneListener,
+ TaskOutcome,
+ TaskRead,
+ TaskRegistration,
+ TaskSnapshot,
+ TaskStatus,
+} from './types.ts'
+
+declare module 'cordis' {
+ interface Context {
+ tasks: TaskService
+ }
+}
+
+/** The registry's mutable per-task record (never handed out — see {@link TaskService.snapshot}). */
+interface TrackedTask {
+ id: TaskId
+ kind: string
+ label: string
+ /** The owner's session id (`session.header.id`), or undefined for an unowned task. */
+ ownerSession: string | undefined
+ cancel: (reason?: string) => void
+ readOutput: (() => string) | undefined
+ status: TaskStatus
+ detail: string | undefined
+ output: string | undefined
+ startedAt: number
+ finishedAt: number | undefined
+ reported: boolean
+ /** Resolves once the terminal snapshot is recorded and listeners notified. */
+ settled: Promise
+ /** Resolver for {@link settled} (called exactly once, by {@link TaskService.settle}). */
+ markSettled: () => void
+ /** Live {@link TaskService.wait} calls — a settlement with waiters marks the task reported. */
+ waiters: number
+}
+
+/** True for the three terminal {@link TaskStatus} values. */
+function isTerminal(status: TaskStatus): boolean {
+ return status === 'completed' || status === 'killed' || status === 'failed'
+}
+
+/**
+ * The `tasks` service: the runtime-global background task registry. See the
+ * module doc for the ownership, isolation, and lifecycle contracts.
+ */
+export class TaskService extends Service {
+ private store = new Map()
+ private counters = new Map()
+ private surfaces = new Set()
+ private listeners = new Set()
+ private listenersClosed = false
+ /** Owner agents that already have this registry's cleanup attached. */
+ private ownerCleanups = new Set()
+ /**
+ * The service's OWN construction-time context, for work that outlives the
+ * calling fiber: detached settlement continuations (logging), and the
+ * owner-cleanup registration on `ctx.agents` (which must survive a producer
+ * plugin's HMR reload, unlike the caller-fiber-scoped effects in
+ * {@link onTaskDone}/{@link attachSurface}).
+ */
+ private readonly selfCtx: Context
+
+ constructor(ctx: Context) {
+ super(ctx, 'tasks')
+ this.selfCtx = ctx
+ ctx.effect(() => () => this.disposeAll(), 'tasks teardown')
+ }
+
+ /**
+ * Register running background work and receive its task id (`-N`,
+ * per-kind counter). The registry attaches ONE continuation to
+ * `registration.done` that records the terminal snapshot, notifies
+ * {@link onTaskDone} listeners, and releases waiters; an owned task also
+ * gets the owner's awaited disposal cleanup attached (once per owner agent)
+ * through `ctx.agents.onCleanup`. Throws when no control surface is
+ * attached ({@link attachSurface}) — a task the model could never read or
+ * stop must fail loud at the start, not dangle — and for an empty
+ * kind/label. ATOMIC: a throw mutates no registry state, so a producer can
+ * cancel its just-started work and rethrow without leaving a stored task
+ * behind.
+ * @param registration - the producer's task contract (see {@link TaskRegistration}).
+ * @returns the registry-issued task id.
+ */
+ register(registration: TaskRegistration): TaskId {
+ if (this.surfaces.size === 0) {
+ throw new Error('background tasks unavailable: no control surface is attached (load @deepseek-ai/dsh-tool-tasks)')
+ }
+ if (registration.kind.length === 0) throw new Error('invalid task kind: expected a non-empty string')
+ if (registration.label.length === 0) throw new Error('invalid task label: expected a non-empty string')
+ // EVERYTHING that can throw runs before any mutation (counter, store):
+ // a failed registration must leave the registry exactly as it was — no
+ // stored-but-unreturned task the producer could never read or kill.
+ if (registration.owner !== undefined) this.ensureOwnerCleanup(registration.owner)
+
+ const count = (this.counters.get(registration.kind) ?? 0) + 1
+ this.counters.set(registration.kind, count)
+ const id = TaskId(`${registration.kind}-${count}`)
+
+ let markSettled!: () => void
+ const settled = new Promise((resolve) => { markSettled = resolve })
+ const task: TrackedTask = {
+ id,
+ kind: registration.kind,
+ label: registration.label,
+ ownerSession: registration.owner?.session.header.id,
+ cancel: registration.cancel.bind(registration),
+ readOutput: registration.readOutput?.bind(registration),
+ status: 'running',
+ detail: undefined,
+ output: undefined,
+ startedAt: Date.now(),
+ finishedAt: undefined,
+ reported: false,
+ settled,
+ markSettled,
+ waiters: 0,
+ }
+ this.store.set(id, task)
+
+ void registration.done.then(
+ (outcome) => { this.settle(task, outcome) },
+ (error: unknown) => {
+ // Producer contract violation (`done` must never reject) — contained
+ // as a failed outcome so waiters, cleanup, and disposal never hang.
+ this.selfCtx.logger.warn(`tasks: task ${task.id} 'done' rejected (producer contract violation): ${String(error)}`)
+ this.settle(task, { status: 'failed', detail: String(error) })
+ },
+ )
+ return id
+ }
+
+ /**
+ * The caller-VISIBLE tasks (owned by the caller's session, or unowned), in
+ * registration order. Never lists another session's tasks — a global
+ * listing would leak their labels across the isolation fence.
+ * @param caller - the reading agent; undefined (a non-agent caller) sees only unowned tasks.
+ * @returns fresh snapshots; mutating them does not affect the registry.
+ */
+ list(caller?: Agent): TaskSnapshot[] {
+ const session = caller?.session.header.id
+ return [...this.store.values()]
+ .filter(task => task.ownerSession === undefined || task.ownerSession === session)
+ .map(task => this.snapshot(task))
+ }
+
+ /**
+ * A non-consuming snapshot of one task — unlike {@link read}, never touches
+ * the stream cursor or the reported flag (the kill surface uses it to
+ * describe an already-terminal task WITHOUT eating a pending delta).
+ * Throws for an unknown id or a task owned by another session.
+ * @param id - the task to look up.
+ * @param caller - the reading agent, checked against the task's owner.
+ * @returns a fresh snapshot.
+ */
+ get(id: TaskId, caller?: Agent): TaskSnapshot {
+ const task = this.expect(id)
+ this.assertAccess(task, caller)
+ return this.snapshot(task)
+ }
+
+ /**
+ * Read a task's output. Stream kinds (registered with `readOutput`) yield
+ * the CONSUMING delta since the previous read — one cursor per task, the
+ * owning model is v1's single intended reader; final-output kinds yield
+ * empty text while live and the terminal output idempotently once settled.
+ * A read that returns the terminal state marks the task {@link TaskSnapshot.reported}.
+ * Throws for an unknown id or a task owned by another session.
+ * @param id - the task to read.
+ * @param caller - the reading agent, checked against the task's owner.
+ * @returns the read text plus the post-read snapshot.
+ */
+ read(id: TaskId, caller?: Agent): TaskRead {
+ const task = this.expect(id)
+ this.assertAccess(task, caller)
+ const text = task.readOutput !== undefined
+ ? task.readOutput()
+ : isTerminal(task.status) ? task.output ?? '' : ''
+ if (isTerminal(task.status)) task.reported = true
+ return { text, snapshot: this.snapshot(task) }
+ }
+
+ /**
+ * Request cancellation of a task. A live task has its producer
+ * `cancel(reason)` invoked FIRST — a throw propagates (fail loud) and
+ * leaves the task untouched (still `running`, notice not suppressed) —
+ * then moves to `stopping` and settles through the normal `done` path; an
+ * already-terminal task is reported, not failed. Every SUCCESSFUL kill
+ * marks the task {@link TaskSnapshot.reported}: the killer has seen (or
+ * asked for) the end, so the completion notice is suppressed. Throws for
+ * an unknown id or a task owned by another session.
+ * @param id - the task to cancel.
+ * @param caller - the killing agent, checked against the task's owner.
+ * @param reason - the surface's logged reason, forwarded to the producer.
+ * @returns 'requested' when cancellation was asked of a live task, 'already-terminal' otherwise.
+ */
+ kill(id: TaskId, caller?: Agent, reason?: string): 'requested' | 'already-terminal' {
+ const task = this.expect(id)
+ this.assertAccess(task, caller)
+ if (isTerminal(task.status)) {
+ task.reported = true
+ return 'already-terminal'
+ }
+ // Producer cancel FIRST: a throw must leave the task untouched (still
+ // `running`, notice not suppressed) — the killer's tool call fails loud,
+ // but task_list and the eventual completion notice keep telling the
+ // truth about a cancellation that never happened. Cancel is synchronous
+ // and settlement lands on a later microtask, so the mutations below
+ // cannot race the settle path.
+ task.cancel(reason)
+ task.status = 'stopping'
+ task.reported = true
+ return 'requested'
+ }
+
+ /**
+ * Wait for a task to settle, bounded by a timeout. Resolves with the
+ * terminal snapshot (marked {@link TaskSnapshot.reported} — the wait
+ * response reports the end, so the completion notice is suppressed), or
+ * with the still-live snapshot when the timeout expires first. An abort of
+ * `signal` rejects the WAIT only — the task keeps running. Throws for an
+ * unknown id, a task owned by another session, or a non-positive timeout.
+ * @param id - the task to wait for.
+ * @param timeoutMs - max wait in milliseconds (positive, finite; the surface caps it).
+ * @param caller - the waiting agent, checked against the task's owner.
+ * @param signal - optional abort for the wait itself.
+ * @returns the snapshot at settlement, or at timeout when the task outlives the wait.
+ */
+ async wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise {
+ const task = this.expect(id)
+ this.assertAccess(task, caller)
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
+ throw new Error(`invalid wait timeout: expected a positive number of milliseconds, got ${JSON.stringify(timeoutMs)}`)
+ }
+ if (!isTerminal(task.status)) {
+ if (signal?.aborted) throw new Error('wait aborted')
+ task.waiters += 1
+ try {
+ await new Promise((resolve, reject) => {
+ const cleanup = (): void => {
+ clearTimeout(timer)
+ signal?.removeEventListener('abort', onAbort)
+ }
+ const timer = setTimeout(() => { cleanup(); resolve() }, timeoutMs)
+ const onAbort = (): void => { cleanup(); reject(new Error('wait aborted')) }
+ signal?.addEventListener('abort', onAbort, { once: true })
+ void task.settled.then(() => { cleanup(); resolve() })
+ })
+ } finally {
+ task.waiters -= 1
+ }
+ }
+ if (isTerminal(task.status)) task.reported = true
+ return this.snapshot(task)
+ }
+
+ /**
+ * Register a completion listener, called exactly once per task with the
+ * terminal snapshot. Effect-scoped (disposed with the calling fiber);
+ * per-listener containment (one throwing listener is logged, never starves
+ * the rest); never fires after this service is disposed.
+ * @param listener - called with each settling task's terminal snapshot.
+ * @returns the disposer that unregisters the listener.
+ */
+ onTaskDone(listener: TaskDoneListener): () => void {
+ const dispose = this.ctx.effect(() => {
+ this.listeners.add(listener)
+ return () => this.listeners.delete(listener)
+ }, 'tasks.onTaskDone()')
+ return () => void dispose()
+ }
+
+ /**
+ * Declare that a control surface capable of reading/stopping tasks is
+ * loaded. {@link register} refuses to start a background task while NO
+ * surface is attached — the loud fence against a deployment exposing
+ * `run_in_background` without any way to collect or stop the work. The
+ * model-facing `@deepseek-ai/dsh-tool-tasks` attaches on load; a deployment
+ * with a custom (non-model) surface attaches its own. Effect-scoped:
+ * detached with the calling fiber.
+ * @param name - a diagnostic label for the surface (duplicate names count independently).
+ * @returns the disposer that detaches the surface.
+ */
+ attachSurface(name: string): () => void {
+ // One token per attach call: duplicate names stay independent, and the
+ // single-shot effect disposer removes exactly its own attachment.
+ const token = Symbol(name)
+ const dispose = this.ctx.effect(() => {
+ this.surfaces.add(token)
+ return () => this.surfaces.delete(token)
+ }, 'tasks.attachSurface()')
+ return () => void dispose()
+ }
+
+ /** Look up a task or fail loud. */
+ private expect(id: TaskId): TrackedTask {
+ const task = this.store.get(id)
+ if (task === undefined) throw new Error(`unknown task ${id}`)
+ return task
+ }
+
+ /**
+ * The isolation fence: a task with an owner is reachable only by callers
+ * whose session id matches (`!== undefined` semantics — an unowned task is
+ * open, and a no-agent caller can never match an owned one).
+ */
+ private assertAccess(task: TrackedTask, caller?: Agent): void {
+ if (task.ownerSession !== undefined && task.ownerSession !== caller?.session.header.id) {
+ throw new Error(`task ${task.id} belongs to another session`)
+ }
+ }
+
+ /** Project a fresh read-only snapshot from the mutable record. */
+ private snapshot(task: TrackedTask): TaskSnapshot {
+ return {
+ id: task.id,
+ kind: task.kind,
+ label: task.label,
+ ...task.ownerSession !== undefined ? { ownerSession: task.ownerSession } : {},
+ status: task.status,
+ ...task.detail !== undefined ? { detail: task.detail } : {},
+ startedAt: task.startedAt,
+ ...task.finishedAt !== undefined ? { finishedAt: task.finishedAt } : {},
+ reported: task.reported,
+ }
+ }
+
+ /**
+ * Record a task's terminal outcome (called exactly once — the single `done`
+ * continuation is the only caller), notify listeners with containment, then
+ * release waiters. A settlement observed by a pending {@link wait} marks
+ * the task reported BEFORE listeners run, so the notice surface can
+ * suppress its redundant "finished".
+ */
+ private settle(task: TrackedTask, outcome: TaskOutcome): void {
+ task.status = outcome.status
+ task.detail = outcome.detail
+ task.output = outcome.output
+ task.finishedAt = Date.now()
+ if (task.waiters > 0) task.reported = true
+ if (!this.listenersClosed) {
+ const snapshot = this.snapshot(task)
+ for (const listener of this.listeners) {
+ try {
+ listener(snapshot)
+ } catch (error: unknown) {
+ this.selfCtx.logger.warn(`tasks: onTaskDone listener threw for ${task.id}: ${String(error)}`)
+ }
+ }
+ }
+ task.markSettled()
+ }
+
+ /**
+ * Attach the awaited owner-disposal cleanup for an owner agent, once: when
+ * the agent's disposal chain drains (`ctx.agents.drainCleanups`), the
+ * owner's still-live tasks are cancelled, awaited to settlement, and their
+ * snapshots dropped. Registered through {@link selfCtx} so the cleanup
+ * survives producer-plugin reloads. Fails loud when no agent registry is
+ * mounted — an owned background task without the cleanup seam would outlive
+ * its owner silently.
+ */
+ private ensureOwnerCleanup(owner: Agent): void {
+ if (this.ownerCleanups.has(owner.id)) return
+ const agents = this.selfCtx.get('agents')
+ if (agents === undefined) {
+ throw new Error('background task ownership requires the agent registry (load @deepseek-ai/dsh-agent)')
+ }
+ // Attach FIRST, record after: onCleanup throws for an unregistered agent,
+ // and marking the owner as covered before that would make every later
+ // registration for the same owner silently skip the cleanup.
+ agents.onCleanup(owner.id, async () => {
+ this.ownerCleanups.delete(owner.id)
+ await this.disposeOwned(owner.session.header.id)
+ })
+ this.ownerCleanups.add(owner.id)
+ }
+
+ /** Cancel (contained), await, and drop every task owned by one session. */
+ private async disposeOwned(ownerSession: string): Promise {
+ const owned = [...this.store.values()].filter(task => task.ownerSession === ownerSession)
+ this.cancelForTeardown(owned, 'owner disposed')
+ await Promise.all(owned.map(task => task.settled))
+ for (const task of owned) this.store.delete(task.id)
+ }
+
+ /**
+ * Service teardown: close the listener registry FIRST (late completions
+ * from teardown kills stay silent), cancel every live task, and await
+ * quiescence. No orphan child work survives the tasks fiber.
+ */
+ private async disposeAll(): Promise {
+ this.listenersClosed = true
+ this.listeners.clear()
+ const all = [...this.store.values()]
+ this.cancelForTeardown(all, 'tasks service disposed')
+ await Promise.all(all.map(task => task.settled))
+ this.store.clear()
+ }
+
+ /**
+ * Teardown-path cancellation with per-task containment: unlike the
+ * model-facing {@link kill} (where a throwing producer `cancel` should fail
+ * the tool call loudly), a teardown must reach quiescence past a broken
+ * producer, so a throw is logged and the sweep continues.
+ */
+ private cancelForTeardown(tasks: TrackedTask[], reason: string): void {
+ for (const task of tasks) {
+ if (isTerminal(task.status)) continue
+ task.status = 'stopping'
+ try {
+ task.cancel(reason)
+ } catch (error: unknown) {
+ this.selfCtx.logger.warn(`tasks: cancel of ${task.id} threw during teardown: ${String(error)}`)
+ }
+ }
+ }
+}
+
+export default TaskService
diff --git a/packages/tasks/tasks/src/types.ts b/packages/tasks/tasks/src/types.ts
new file mode 100644
index 0000000000..09b1911350
--- /dev/null
+++ b/packages/tasks/tasks/src/types.ts
@@ -0,0 +1,155 @@
+/**
+ * Task-registry vocabulary: the registration a producer hands to
+ * {@link TaskService.register} and the snapshots/reads consumers get back.
+ * Types only — the service lives in `./index.ts`.
+ *
+ * @module @deepseek-ai/dsh-tasks/types
+ */
+
+import type { Branded } from '@deepseek-ai/dsh-brand'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+
+/**
+ * Identifies one background task in the runtime-global registry. Generated by
+ * {@link TaskService.register} as `-N` (per-kind counter) — kind-prefixed
+ * so transcripts stay self-describing, sequential because the owner fence (not
+ * id secrecy) is the isolation boundary.
+ */
+export type TaskId = Branded<'TaskId'>
+
+/**
+ * Brand a string as a {@link TaskId}.
+ * @param id - the raw task-id string (the registry generates `-N`).
+ * @returns the same string, branded; no validation is performed.
+ */
+export function TaskId(id: string): TaskId {
+ return id as TaskId
+}
+
+/**
+ * Task lifecycle. `running` → (`stopping` when cancellation was requested) →
+ * exactly one terminal {@link TaskOutcome.status} (`completed`, `killed`,
+ * `failed`). The vocabulary is generic and CLOSED — kind-specific meaning
+ * (exit codes, stop reasons) rides in {@link TaskSnapshot.detail}, so the
+ * registry never learns process or agent semantics.
+ */
+export type TaskStatus = 'running' | 'stopping' | 'completed' | 'killed' | 'failed'
+
+/**
+ * The terminal result a producer's {@link TaskRegistration.done} resolves
+ * with, mapped from the producer's own vocabulary (a process exit, a subagent
+ * stop reason) into the registry's closed status set.
+ */
+export interface TaskOutcome {
+ /** How the task ended: finished (`completed`), cancelled (`killed`), or broke (`failed`). */
+ status: 'completed' | 'killed' | 'failed'
+ /** Kind-specific detail rendered into status lines ('exit code: 3', 'max-tokens'). */
+ detail?: string
+ /**
+ * Final output for FINAL-OUTPUT-ONLY kinds (no {@link TaskRegistration.readOutput}),
+ * read idempotently after the task settles. Stream kinds leave it unset —
+ * their output is consumed incrementally through `readOutput`.
+ */
+ output?: string
+}
+
+/**
+ * What a producer registers with {@link TaskService.register}: the running
+ * work's identity, its owner, and the three hooks the registry drives it
+ * through. The producer stays the owner of its execution concerns (process
+ * streams, child agents); the registry owns ids, isolation, status, and
+ * completion fan-out.
+ */
+export interface TaskRegistration {
+ /** Producer kind — also the id prefix (`bash`, `subagent`, …). Non-empty. */
+ kind: string
+ /** One-line model-facing label (the command; the delegation description). */
+ label: string
+ /**
+ * The spawning agent. Its `session.header.id` becomes the task's owner
+ * token (read/kill/wait/list are fenced to that session), and its disposal
+ * cancels and awaits the task through the `ctx.agents.onCleanup` seam.
+ * `undefined` registers an UNOWNED task: open to any caller, alive until the
+ * tasks service disposes.
+ */
+ owner?: Agent | undefined
+ /**
+ * Request termination. Idempotent, synchronous, and must lead to
+ * {@link done} settling; a throw propagates to the killer (fail loud — a
+ * cancel that cannot even be requested is a producer bug). The optional
+ * reason is `task_kill`'s logged reason, forwarded verbatim.
+ */
+ cancel(reason?: string): void
+ /**
+ * Settles with the terminal outcome at QUIESCENCE — after the producer has
+ * released the task's resources (process exited, child agent disposed) —
+ * not merely when the work finished. Must never reject; a rejection is
+ * contained as a `failed` outcome and logged as a producer contract
+ * violation.
+ */
+ done: Promise
+ /**
+ * OPTIONAL incremental read (stream kinds): everything produced since the
+ * previous call, formatted by the producer (truncation/spill notices
+ * included). Consecutive calls never re-deliver output; the registry keeps
+ * ONE consuming cursor per task, so v1's single intended reader is the
+ * owning model. Absence marks a final-output-only kind (the method presence
+ * IS the capability).
+ */
+ readOutput?(): string
+}
+
+/**
+ * A read-only projection of one task, safe to hand to listeners and tools —
+ * a fresh object per call, never live registry state.
+ */
+export interface TaskSnapshot {
+ /** The registry-issued id (`-N`). */
+ id: TaskId
+ /** The producer kind the task was registered with. */
+ kind: string
+ /** The producer-supplied one-line label. */
+ label: string
+ /**
+ * The owner's session id (`session.header.id`), for surfaces that must
+ * reach the owning agent (the completion-notice injector); absent for
+ * unowned tasks. Session ids are runtime-shared identifiers, not secrets —
+ * the read/kill/wait/list FENCE is what isolation rests on.
+ */
+ ownerSession?: string
+ /** Current lifecycle state. */
+ status: TaskStatus
+ /** Kind-specific status detail, present once the producer supplied one (usually terminal). */
+ detail?: string
+ /** Epoch ms when the task was registered. */
+ startedAt: number
+ /** Epoch ms when the task settled; absent while `running`/`stopping`. */
+ finishedAt?: number
+ /**
+ * True once the terminal state has been (or is being) reported to the owner
+ * through an explicit surface response — a `kill` call, or a `read`/`wait`
+ * that returned the terminal state (including a wait pending at settlement).
+ * Completion-notice surfaces suppress their notice when set, so the model
+ * never gets a redundant "finished" for a task it just collected or killed.
+ */
+ reported: boolean
+}
+
+/**
+ * One {@link TaskService.read}: the output text this read yields (may be
+ * empty — the surface decides how to render "nothing new") plus the snapshot
+ * taken after the read.
+ */
+export interface TaskRead {
+ /**
+ * Stream kinds: the consuming delta since the previous read. Final-output
+ * kinds: empty while live, the terminal {@link TaskOutcome.output} (or
+ * empty) once settled — idempotent, never consumed.
+ */
+ text: string
+ /** The task's state at read time. */
+ snapshot: TaskSnapshot
+}
+
+/** Completion callback registered via {@link TaskService.onTaskDone}. */
+export type TaskDoneListener = (snapshot: TaskSnapshot) => void
diff --git a/packages/tasks/tasks/tests/tasks.spec.ts b/packages/tasks/tasks/tests/tasks.spec.ts
new file mode 100644
index 0000000000..87a71cd565
--- /dev/null
+++ b/packages/tasks/tasks/tests/tasks.spec.ts
@@ -0,0 +1,465 @@
+import { describe, expect, it, vi } from 'vitest'
+import { Context } from 'cordis'
+import { Session, SessionId } from '@deepseek-ai/dsh-session'
+import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import TaskService, { TaskId } from '@deepseek-ai/dsh-tasks'
+import type { TaskOutcome, TaskRegistration, TaskSnapshot } from '@deepseek-ai/dsh-tasks'
+
+function stubAgent(rawId: string): Agent {
+ const id = AgentId(rawId)
+ return {
+ id,
+ options: {},
+ session: new Session(SessionId(`${id}-session`)),
+ status: 'idle',
+ send() {},
+ steer() {},
+ inject() {},
+ cancel() {},
+ whenIdle() { return Promise.resolve() },
+ }
+}
+
+/** A controllable producer: settle its `done` on demand, record cancels. */
+function producer(overrides: Partial = {}) {
+ let settle!: (outcome: TaskOutcome) => void
+ let reject!: (error: unknown) => void
+ const cancels: (string | undefined)[] = []
+ const registration: TaskRegistration = {
+ kind: 'bash',
+ label: 'sleep 60',
+ cancel(reason) { cancels.push(reason) },
+ done: new Promise((res, rej) => { settle = res; reject = rej }),
+ ...overrides,
+ }
+ return { registration, settle, reject, cancels }
+}
+
+async function harness() {
+ const ctx = new Context()
+ await ctx.plugin(AgentRegistry)
+ await ctx.plugin(TaskService)
+ ctx.tasks.attachSurface('test-surface')
+ return ctx
+}
+
+/** Let the settlement continuation (a `done.then`) run. */
+const tick = () => new Promise(r => setTimeout(r, 0))
+
+describe('TaskService.register', () => {
+ it('refuses to register while no control surface is attached', async () => {
+ const ctx = new Context()
+ await ctx.plugin(TaskService)
+ expect(() => ctx.tasks.register(producer().registration))
+ .toThrow('background tasks unavailable: no control surface is attached (load @deepseek-ai/dsh-tool-tasks)')
+ })
+
+ it('rejects an empty kind and an empty label', async () => {
+ const ctx = await harness()
+ expect(() => ctx.tasks.register(producer({ kind: '' }).registration)).toThrow('invalid task kind')
+ expect(() => ctx.tasks.register(producer({ label: '' }).registration)).toThrow('invalid task label')
+ })
+
+ it('issues kind-prefixed ids from per-kind counters', async () => {
+ const ctx = await harness()
+ expect(ctx.tasks.register(producer().registration)).toBe('bash-1')
+ expect(ctx.tasks.register(producer().registration)).toBe('bash-2')
+ expect(ctx.tasks.register(producer({ kind: 'subagent' }).registration)).toBe('subagent-1')
+ })
+})
+
+describe('TaskService reads and settlement', () => {
+ it('stream kinds read a consuming delta; terminal reads mark reported', async () => {
+ const ctx = await harness()
+ const chunks = ['first', '', 'rest']
+ const p = producer({ readOutput: () => chunks.shift() ?? '' })
+ const id = ctx.tasks.register(p.registration)
+
+ expect(ctx.tasks.read(id)).toMatchObject({ text: 'first', snapshot: { status: 'running', reported: false } })
+ expect(ctx.tasks.read(id).text).toBe('')
+
+ p.settle({ status: 'completed', detail: 'exit code: 0' })
+ await tick()
+ const read = ctx.tasks.read(id)
+ expect(read.text).toBe('rest')
+ expect(read.snapshot).toMatchObject({ status: 'completed', detail: 'exit code: 0', reported: true })
+ expect(read.snapshot.finishedAt).toBeTypeOf('number')
+ })
+
+ it('final-output kinds read empty while live, the outcome output idempotently once settled', async () => {
+ const ctx = await harness()
+ const p = producer({ kind: 'subagent', label: 'research task' })
+ const id = ctx.tasks.register(p.registration)
+
+ expect(ctx.tasks.read(id)).toMatchObject({ text: '', snapshot: { status: 'running' } })
+
+ p.settle({ status: 'completed', output: 'final answer' })
+ await tick()
+ expect(ctx.tasks.read(id).text).toBe('final answer')
+ expect(ctx.tasks.read(id).text).toBe('final answer') // idempotent, not consumed
+ })
+
+ it('a settled task without output reads as empty text', async () => {
+ const ctx = await harness()
+ const p = producer({ kind: 'subagent' })
+ const id = ctx.tasks.register(p.registration)
+ p.settle({ status: 'failed', detail: 'max-tokens' })
+ await tick()
+ expect(ctx.tasks.read(id)).toMatchObject({ text: '', snapshot: { status: 'failed', detail: 'max-tokens' } })
+ })
+
+ it('throws for unknown task ids', async () => {
+ const ctx = await harness()
+ expect(() => ctx.tasks.read(TaskId('bash-99'))).toThrow('unknown task bash-99')
+ })
+
+ it('notifies onTaskDone once per task with containment across listeners', async () => {
+ const ctx = await harness()
+ const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
+ const seen: TaskSnapshot[] = []
+ ctx.tasks.onTaskDone(() => { throw new Error('listener boom') })
+ ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot))
+
+ const p = producer()
+ const id = ctx.tasks.register(p.registration)
+ p.settle({ status: 'completed', detail: 'exit code: 0' })
+ await tick()
+
+ expect(seen).toHaveLength(1)
+ expect(seen[0]).toMatchObject({ id, status: 'completed', reported: false })
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('listener boom'))
+ })
+
+ it('contains a rejecting done as a failed outcome (producer contract violation)', async () => {
+ const ctx = await harness()
+ const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
+ const p = producer()
+ const id = ctx.tasks.register(p.registration)
+ p.reject(new Error('transport exploded'))
+ await tick()
+
+ expect(ctx.tasks.read(id).snapshot).toMatchObject({ status: 'failed', detail: 'Error: transport exploded' })
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('producer contract violation'))
+ })
+
+ it('unregisters onTaskDone listeners with the contributing fiber (HMR safety)', async () => {
+ const ctx = await harness()
+ const seen: string[] = []
+ const fiber = await ctx.plugin(Object.assign((inner: Context) => {
+ inner.tasks.onTaskDone(snapshot => void seen.push(snapshot.id))
+ }, { inject: ['tasks'] }))
+ await fiber.dispose()
+ // The returned disposer detaches too (the non-fiber path).
+ const detach = ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot.id))
+ detach()
+
+ const p = producer()
+ ctx.tasks.register(p.registration)
+ p.settle({ status: 'completed' })
+ await tick()
+ expect(seen).toEqual([])
+ })
+})
+
+describe('TaskService.kill', () => {
+ it('cancels a live task with the forwarded reason and suppresses the notice', async () => {
+ const ctx = await harness()
+ const seen: TaskSnapshot[] = []
+ ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot))
+ const p = producer()
+ const id = ctx.tasks.register(p.registration)
+
+ expect(ctx.tasks.kill(id, undefined, 'no longer needed')).toBe('requested')
+ expect(p.cancels).toEqual(['no longer needed'])
+ expect(ctx.tasks.list()[0]).toMatchObject({ status: 'stopping', reported: true })
+
+ p.settle({ status: 'killed' })
+ await tick()
+ // The listener still fires (telemetry may care), but carries reported: true
+ // so the notice surface suppresses its redundant "finished".
+ expect(seen[0]).toMatchObject({ id, status: 'killed', reported: true })
+ })
+
+ it('reports an already-terminal task instead of failing', async () => {
+ const ctx = await harness()
+ const p = producer()
+ const id = ctx.tasks.register(p.registration)
+ p.settle({ status: 'completed' })
+ await tick()
+ expect(ctx.tasks.kill(id)).toBe('already-terminal')
+ })
+
+ it('propagates a throwing producer cancel and leaves the task untouched', async () => {
+ const ctx = await harness()
+ const seen: TaskSnapshot[] = []
+ ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot))
+ let broken = true
+ let settle!: (outcome: TaskOutcome) => void
+ const id = ctx.tasks.register({
+ kind: 'bash',
+ label: 'flaky cancel',
+ cancel() { if (broken) throw new Error('cancel boom') },
+ done: new Promise((res) => { settle = res }),
+ })
+ expect(() => ctx.tasks.kill(id)).toThrow('cancel boom')
+ // The failed kill mutated NOTHING: still running, notice not suppressed,
+ // and a later (successful) kill still works.
+ expect(ctx.tasks.get(id)).toMatchObject({ status: 'running', reported: false })
+ settle({ status: 'completed' })
+ await tick()
+ expect(seen[0]).toMatchObject({ id, reported: false }) // notice would still fire
+
+ broken = false
+ expect(ctx.tasks.kill(id)).toBe('already-terminal')
+ })
+})
+
+describe('TaskService.wait', () => {
+ it('resolves with the terminal snapshot when the task settles, marked reported', async () => {
+ const ctx = await harness()
+ const seen: TaskSnapshot[] = []
+ ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot))
+ const p = producer()
+ const id = ctx.tasks.register(p.registration)
+
+ const wait = ctx.tasks.wait(id, 5_000)
+ p.settle({ status: 'completed', detail: 'exit code: 0' })
+ expect(await wait).toMatchObject({ status: 'completed', reported: true })
+ // The pending wait marked the task reported BEFORE listeners ran.
+ expect(seen[0]).toMatchObject({ id, reported: true })
+ })
+
+ it('returns the live snapshot on timeout without marking reported', async () => {
+ const ctx = await harness()
+ const id = ctx.tasks.register(producer().registration)
+ expect(await ctx.tasks.wait(id, 5)).toMatchObject({ status: 'running', reported: false })
+ })
+
+ it('returns immediately for an already-terminal task', async () => {
+ const ctx = await harness()
+ const p = producer()
+ const id = ctx.tasks.register(p.registration)
+ p.settle({ status: 'completed' })
+ await tick()
+ expect(await ctx.tasks.wait(id, 5_000)).toMatchObject({ status: 'completed', reported: true })
+ })
+
+ it('rejects a non-positive or non-finite timeout', async () => {
+ const ctx = await harness()
+ const id = ctx.tasks.register(producer().registration)
+ await expect(ctx.tasks.wait(id, 0)).rejects.toThrow('invalid wait timeout')
+ await expect(ctx.tasks.wait(id, Number.NaN)).rejects.toThrow('invalid wait timeout')
+ })
+
+ it('an aborted signal rejects the wait only — the task stays alive', async () => {
+ const ctx = await harness()
+ const id = ctx.tasks.register(producer().registration)
+
+ const controller = new AbortController()
+ const wait = ctx.tasks.wait(id, 5_000, undefined, controller.signal)
+ controller.abort()
+ await expect(wait).rejects.toThrow('wait aborted')
+ expect(ctx.tasks.list()[0]).toMatchObject({ status: 'running' })
+
+ const preAborted = new AbortController()
+ preAborted.abort()
+ await expect(ctx.tasks.wait(id, 5_000, undefined, preAborted.signal)).rejects.toThrow('wait aborted')
+ })
+})
+
+describe('TaskService owner isolation', () => {
+ it('fences read/kill/wait to the owning session and keeps unowned tasks open', async () => {
+ const ctx = await harness()
+ const owner = stubAgent('owner')
+ ctx.agents.register(owner)
+ const other = stubAgent('other')
+
+ const owned = ctx.tasks.register(producer({ owner }).registration)
+ const open = ctx.tasks.register(producer().registration)
+
+ // The owner and the unowned task are reachable.
+ expect(ctx.tasks.read(owned, owner).snapshot.id).toBe(owned)
+ expect(ctx.tasks.read(open, other).snapshot.id).toBe(open)
+
+ // A different session and a no-agent caller are rejected.
+ expect(() => ctx.tasks.read(owned, other)).toThrow(`task ${owned} belongs to another session`)
+ expect(() => ctx.tasks.kill(owned, other)).toThrow('belongs to another session')
+ await expect(ctx.tasks.wait(owned, 10, other)).rejects.toThrow('belongs to another session')
+ expect(() => ctx.tasks.read(owned)).toThrow('belongs to another session')
+ })
+
+ it('list() shows only caller-owned plus unowned tasks', async () => {
+ const ctx = await harness()
+ const alice = stubAgent('alice')
+ const bob = stubAgent('bob')
+ ctx.agents.register(alice)
+ ctx.agents.register(bob)
+
+ const aliceTask = ctx.tasks.register(producer({ owner: alice }).registration)
+ const bobTask = ctx.tasks.register(producer({ owner: bob }).registration)
+ const openTask = ctx.tasks.register(producer({ kind: 'subagent' }).registration)
+
+ expect(ctx.tasks.list(alice).map(t => t.id)).toEqual([aliceTask, openTask])
+ expect(ctx.tasks.list(bob).map(t => t.id)).toEqual([bobTask, openTask])
+ expect(ctx.tasks.list().map(t => t.id)).toEqual([openTask])
+ })
+
+ it('rejects an owned registration when no agent registry is mounted', async () => {
+ const ctx = new Context()
+ await ctx.plugin(TaskService)
+ ctx.tasks.attachSurface('test-surface')
+ expect(() => ctx.tasks.register(producer({ owner: stubAgent('a') }).registration))
+ .toThrow('background task ownership requires the agent registry')
+ // The failed registration mutated nothing: no stored task, counter untouched.
+ expect(ctx.tasks.list()).toEqual([])
+ expect(ctx.tasks.register(producer().registration)).toBe('bash-1')
+ })
+
+ it('a failed owner-cleanup attach leaves the registry unchanged and does not poison the owner', async () => {
+ const ctx = await harness()
+ const ghost = stubAgent('ghost') // never registered in ctx.agents
+
+ // onCleanup rejects the unregistered agent BEFORE any registry mutation.
+ expect(() => ctx.tasks.register(producer({ owner: ghost }).registration))
+ .toThrow('is not registered')
+ expect(ctx.tasks.list(ghost)).toEqual([])
+
+ // Once the agent actually exists, the same owner gets a WORKING cleanup —
+ // the failed attempt must not have marked it as already covered.
+ ctx.agents.register(ghost)
+ const cancels: (string | undefined)[] = []
+ let settle!: (outcome: TaskOutcome) => void
+ const id = ctx.tasks.register({
+ kind: 'bash',
+ label: 'after retry',
+ owner: ghost,
+ cancel(reason) { cancels.push(reason); settle({ status: 'killed' }) },
+ done: new Promise((res) => { settle = res }),
+ })
+ expect(id).toBe('bash-1') // the failed attempt burned no counter
+ await ctx.agents.drainCleanups(ghost.id)
+ expect(cancels).toEqual(['owner disposed'])
+ expect(ctx.tasks.list(ghost)).toEqual([])
+ })
+})
+
+describe('TaskService owner cleanup', () => {
+ it('drains the owner: cancels live tasks, awaits settlement, drops snapshots', async () => {
+ const ctx = await harness()
+ const owner = stubAgent('owner')
+ ctx.agents.register(owner)
+
+ // The producer settles only when cancelled — models a child that stops on request.
+ let settle!: (outcome: TaskOutcome) => void
+ const cancels: (string | undefined)[] = []
+ ctx.tasks.register({
+ kind: 'subagent',
+ label: 'long research',
+ owner,
+ cancel(reason) { cancels.push(reason); settle({ status: 'killed' }) },
+ done: new Promise((res) => { settle = res }),
+ })
+ const terminal = producer({ owner })
+ ctx.tasks.register(terminal.registration)
+ terminal.settle({ status: 'completed' })
+ await tick()
+
+ await ctx.agents.drainCleanups(owner.id)
+ expect(cancels).toEqual(['owner disposed'])
+ // Snapshots dropped: nothing of the owner's remains, listing is empty.
+ expect(ctx.tasks.list(owner)).toEqual([])
+ })
+
+ it('attaches one cleanup per owner and re-attaches after a drain', async () => {
+ const ctx = await harness()
+ const owner = stubAgent('owner')
+ ctx.agents.register(owner)
+
+ const first = producer({ owner })
+ const second = producer({ owner })
+ ctx.tasks.register(first.registration)
+ ctx.tasks.register(second.registration)
+ first.settle({ status: 'completed' })
+ second.settle({ status: 'completed' })
+ await tick()
+ await ctx.agents.drainCleanups(owner.id)
+
+ // A fresh task after the drain gets a fresh cleanup (the set was consumed).
+ const third = producer({ owner })
+ ctx.tasks.register(third.registration)
+ third.settle({ status: 'completed' })
+ await tick()
+ expect(ctx.tasks.list(owner)).toHaveLength(1)
+ await ctx.agents.drainCleanups(owner.id)
+ expect(ctx.tasks.list(owner)).toEqual([])
+ })
+
+ it('contains a throwing producer cancel on the cleanup path', async () => {
+ const ctx = await harness()
+ const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
+ const owner = stubAgent('owner')
+ ctx.agents.register(owner)
+
+ let settle!: (outcome: TaskOutcome) => void
+ ctx.tasks.register({
+ kind: 'bash',
+ label: 'broken producer',
+ owner,
+ cancel() { throw new Error('cancel boom') },
+ done: new Promise((res) => { settle = res }),
+ })
+
+ const drain = ctx.agents.drainCleanups(owner.id)
+ settle({ status: 'failed', detail: 'gave up' })
+ await drain
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('cancel boom'))
+ expect(ctx.tasks.list(owner)).toEqual([])
+ })
+})
+
+describe('TaskService disposal', () => {
+ it('cancels live tasks, awaits settlement, and silences listeners', async () => {
+ const ctx = new Context()
+ await ctx.plugin(AgentRegistry)
+ const fiber = await ctx.plugin(TaskService)
+ const surface = await ctx.plugin(Object.assign((inner: Context) => {
+ inner.tasks.attachSurface('test-surface')
+ }, { inject: ['tasks'] }))
+ void surface
+
+ const seen: string[] = []
+ ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot.id))
+ let settle!: (outcome: TaskOutcome) => void
+ const cancels: (string | undefined)[] = []
+ ctx.tasks.register({
+ kind: 'bash',
+ label: 'sleep 600',
+ cancel(reason) { cancels.push(reason); settle({ status: 'killed' }) },
+ done: new Promise((res) => { settle = res }),
+ })
+
+ await fiber.dispose()
+ expect(cancels).toEqual(['tasks service disposed'])
+ // The teardown kill settles AFTER the listener registry closed: silent.
+ expect(seen).toEqual([])
+ })
+
+ it('detaching the last surface re-arms the register fence', async () => {
+ const ctx = new Context()
+ await ctx.plugin(TaskService)
+ const detachA1 = ctx.tasks.attachSurface('a')
+ const detachA2 = ctx.tasks.attachSurface('a') // duplicate name counts independently
+ const fiber = await ctx.plugin(Object.assign((inner: Context) => {
+ inner.tasks.attachSurface('b')
+ }, { inject: ['tasks'] }))
+
+ detachA1()
+ detachA1() // second call of the same disposer is a no-op
+ expect(() => ctx.tasks.register(producer().registration)).not.toThrow() // a ×1 + b remain
+ detachA2()
+ expect(() => ctx.tasks.register(producer().registration)).not.toThrow() // b remains
+ await fiber.dispose() // detaches b with its fiber (HMR safety)
+ expect(() => ctx.tasks.register(producer().registration)).toThrow('no control surface is attached')
+ })
+})
diff --git a/packages/tasks/tasks/tsconfig.json b/packages/tasks/tasks/tsconfig.json
new file mode 100644
index 0000000000..97fb432ffb
--- /dev/null
+++ b/packages/tasks/tasks/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": "../../util/brand"
+ },
+ {
+ "path": "../../core/agent"
+ }
+ ]
+}
diff --git a/packages/tasks/tool-tasks/README.md b/packages/tasks/tool-tasks/README.md
new file mode 100644
index 0000000000..0548526474
--- /dev/null
+++ b/packages/tasks/tool-tasks/README.md
@@ -0,0 +1,24 @@
+# @deepseek-ai/dsh-tool-tasks
+
+The model-facing background task control surface over `ctx.tasks`: three kind-agnostic tools, the completion-notice injection, and the prompt section that teaches the background habit. Loading this plugin calls `ctx.tasks.attachSurface('tool-tasks')`, which is what arms producers' `register()`.
+
+## Tools
+
+- `task_output(task_id, wait?, timeout_ms?)` — non-blocking read by default (stream kinds: the consuming delta since the previous read; final kinds: the final answer once terminal); every response ends with a `[status: …]` line (generic status + producer detail, e.g. `[status: completed, exit code: 0]`). `wait: true` blocks until settlement, bounded by `waitTimeoutMs`/`maxWaitTimeoutMs` config; a timed-out wait returns `[status: running]` and leaves the task alive.
+- `task_list()` — the caller's tasks, ` [] — ` per line.
+- `task_kill(task_id, reason?)` — requests cancellation and returns immediately; the logged `reason` is forwarded to the producer. An already-terminal task is described via a non-consuming snapshot (never eats a pending delta).
+
+ACP render intent: all three are `generic` cards (`read`/`read`/`execute`) — a task read is not a terminal.
+
+## Completion notices
+
+On `onTaskDone`, injects `background task (: ) finished [status: …]. Read its output with task_output.` into the owning agent's session (`agent.inject()` — durable context for the next request, not a wake-up). Suppressed when the snapshot is `reported` (the model already killed it, or a read/wait returned the end) — never a redundant "finished". The disposed-owner race is contained; a missing agent registry drops the notice.
+
+## Config
+
+| key | default | meaning |
+|---|---|---|
+| `waitTimeoutMs` | `30000` | wait duration when `task_output` sets `wait` without `timeout_ms` |
+| `maxWaitTimeoutMs` | `600000` | hard cap; larger model-supplied `timeout_ms` values are clamped |
+
+A config whose default exceeds the cap fails loud at load.
diff --git a/packages/tasks/tool-tasks/package.json b/packages/tasks/tool-tasks/package.json
new file mode 100644
index 0000000000..9e0b19d307
--- /dev/null
+++ b/packages/tasks/tool-tasks/package.json
@@ -0,0 +1,43 @@
+{
+ "name": "@deepseek-ai/dsh-tool-tasks",
+ "description": "Model-facing background task control tools (task_output, task_list, task_kill) over the ctx.tasks registry",
+ "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"
+ },
+ "./src/*": "./src/*",
+ "./package.json": "./package.json"
+ },
+ "files": [
+ "lib/index.js",
+ "lib/types/**/*.d.ts",
+ "lib/types/**/*.d.ts.map",
+ "src"
+ ],
+ "license": "BSD-3-Clause",
+ "peerDependencies": {
+ "@deepseek-ai/dsh-agent": "^0.0.1",
+ "@deepseek-ai/dsh-system-prompt": "^0.0.1",
+ "@deepseek-ai/dsh-tasks": "^0.0.1",
+ "@deepseek-ai/dsh-tools": "^0.0.1",
+ "cordis": "^4.0.0-rc.6"
+ },
+ "dependencies": {
+ "schemastery": "^3.18.0"
+ },
+ "devDependencies": {
+ "@deepseek-ai/dsh-agent": "workspace:^",
+ "@deepseek-ai/dsh-llm": "workspace:^",
+ "@deepseek-ai/dsh-session": "workspace:^",
+ "@deepseek-ai/dsh-system-prompt": "workspace:^",
+ "@deepseek-ai/dsh-tasks": "workspace:^",
+ "@deepseek-ai/dsh-tools": "workspace:^",
+ "cordis": "^4.0.0-rc.6"
+ }
+}
diff --git a/packages/tasks/tool-tasks/src/index.ts b/packages/tasks/tool-tasks/src/index.ts
new file mode 100644
index 0000000000..30ff629e88
--- /dev/null
+++ b/packages/tasks/tool-tasks/src/index.ts
@@ -0,0 +1,183 @@
+/**
+ * The model-facing background task control tools: `task_output`, `task_list`,
+ * `task_kill`. Kind-agnostic — a background bash command and a background
+ * subagent read, list, and die through the same three schemas — with every
+ * task concern (ids, isolation, cursors, settlement) behind the `ctx.tasks`
+ * registry (`@deepseek-ai/dsh-tasks`).
+ *
+ * This plugin IS the control surface: it calls `ctx.tasks.attachSurface()` on
+ * load, which is what re-arms producers' `register()` (the registry refuses
+ * background work while no surface could collect or stop it).
+ *
+ * Completion notices: when a task settles, a short notice is injected into
+ * the owning agent's session (`agent.inject()` — durable context for the NEXT
+ * model request, not a wake-up). A task whose terminal state the model
+ * already saw (`snapshot.reported` — an explicit kill, or a read/wait that
+ * returned the end) is suppressed, so the model never gets a redundant
+ * "finished" for work it just collected.
+ *
+ * @module @deepseek-ai/dsh-tool-tasks
+ */
+
+import type { Context } from 'cordis'
+import z from 'schemastery'
+import { defineTool } from '@deepseek-ai/dsh-tools'
+import type { GenericCallView } from '@deepseek-ai/dsh-tools'
+import { TaskId } from '@deepseek-ai/dsh-tasks'
+import type { TaskSnapshot } from '@deepseek-ai/dsh-tasks'
+import type {} from '@deepseek-ai/dsh-system-prompt'
+
+export const name = 'tool-tasks'
+export const inject = ['tools', 'tasks', 'systemPrompt']
+
+/** Config: the `task_output` wait bounds (defaulted, capped — never hardcoded). */
+export interface Config {
+ /** Wait duration applied when `task_output` sets `wait` without `timeout_ms` (default 30s). */
+ waitTimeoutMs?: number
+ /** Hard cap on any single wait; a larger model-supplied `timeout_ms` is clamped down to it (default 10min). */
+ maxWaitTimeoutMs?: number
+}
+
+export const Config: z = z.object({
+ waitTimeoutMs: z.number().min(1).default(30_000),
+ maxWaitTimeoutMs: z.number().min(1).default(600_000),
+})
+
+/**
+ * Render a snapshot's status line — generic status plus the producer's
+ * kind-specific detail: `[status: completed, exit code: 0]`,
+ * `[status: failed, max-tokens]`, `[status: running]`. Exported for tests
+ * and for producers that want a consistent line in their own results.
+ * @param snapshot - the task state to render.
+ * @returns the bracketed status line.
+ */
+export function statusLine(snapshot: TaskSnapshot): string {
+ return snapshot.detail !== undefined
+ ? `[status: ${snapshot.status}, ${snapshot.detail}]`
+ : `[status: ${snapshot.status}]`
+}
+
+/**
+ * Reject an empty `task_id`. Type/presence come from the SchemaSpec
+ * validation; only the non-empty constraint, which the DSL cannot express,
+ * is checked here.
+ */
+function validateTaskId(value: string): TaskId {
+ if (value.length === 0) {
+ throw new Error(`invalid task_id: expected a non-empty string, got ${JSON.stringify(value)}`)
+ }
+ return TaskId(value)
+}
+
+/** Pending-state presentation shared by the three control tools (generic cards by design — a task read/kill is not a terminal). */
+function presentTaskCall(title: string, kind: 'read' | 'execute', rawInput?: string): GenericCallView {
+ return { card: 'generic', title, kind, ...rawInput !== undefined ? { rawInput } : {} }
+}
+
+export function apply(ctx: Context, config: Config): void {
+ const waitDefault = config.waitTimeoutMs ?? 30_000
+ const waitCap = config.maxWaitTimeoutMs ?? 600_000
+ if (waitDefault > waitCap) {
+ throw new Error(`tool-tasks: waitTimeoutMs (${waitDefault}) exceeds maxWaitTimeoutMs (${waitCap})`)
+ }
+
+ // The registry's misconfiguration fence: producers can register background
+ // work only while a surface capable of collecting/stopping it is attached.
+ ctx.tasks.attachSurface('tool-tasks')
+
+ // The cross-call HABIT the per-tool descriptions cannot carry. Order 106:
+ // right after tool:bash (105), before deployment product sections.
+ ctx.systemPrompt.section({
+ name: 'tool:tasks',
+ order: 106,
+ text: 'Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task\'s work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.',
+ })
+
+ // Background completion → inject a notice into the owning agent's session.
+ // `ctx.get('agents')` (not static inject): this listener runs from a
+ // detached settlement continuation on the tasks fiber — a foreign fiber —
+ // where the `ctx.agents` property proxy would throw; `ctx.get` is the
+ // topology-independent lookup. No registry mounted → drop the notice.
+ ctx.tasks.onTaskDone((snapshot) => {
+ // A reported terminal state was already surfaced by an explicit
+ // read/wait/kill response — a notice would be a redundant "finished".
+ if (snapshot.reported || snapshot.ownerSession === undefined) return
+ const agent = ctx.get('agents')?.list().find(a => a.session.header.id === snapshot.ownerSession)
+ if (!agent) return
+ try {
+ agent.inject(
+ [{ type: 'text', text: `background task ${snapshot.id} (${snapshot.kind}: ${snapshot.label}) finished ${statusLine(snapshot)}. Read its output with task_output.` }],
+ { source: { kind: 'plugin', plugin: 'tool-tasks' } },
+ )
+ } catch (error: unknown) {
+ // The ONE expected failure: the agent was disposed between settlement
+ // and this injection (inject throws `agent "" is disposed`). That
+ // race is benign — drop the notice. Anything else must surface.
+ if (error instanceof Error && error.message.includes('is disposed')) return
+ throw error
+ }
+ })
+
+ ctx.tools.register(defineTool({
+ name: 'task_output',
+ description: 'Read output/status from a background task (started by a tool with `run_in_background`). '
+ + 'Stream tasks (bash) return only output produced since your previous task_output call; '
+ + 'final-output tasks (subagent) return the final answer once the task finishes. '
+ + 'Every response ends with a [status: ...] line. Non-blocking by default; '
+ + 'set `wait: true` to block until the task finishes (bounded by a capped timeout) when you are genuinely blocked on its result.',
+ parameters: {
+ task_id: { type: 'string', required: true, description: 'Task id returned by the tool that started the background work.' },
+ wait: { type: 'boolean', description: 'Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive.' },
+ timeout_ms: { type: 'number', description: 'Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum.' },
+ },
+ async execute(args, exec) {
+ const id = validateTaskId(args.task_id)
+ if (args.wait === true) {
+ const timeout = Math.min(args.timeout_ms ?? waitDefault, waitCap)
+ await ctx.tasks.wait(id, timeout, exec.agent, exec.signal)
+ }
+ const read = ctx.tasks.read(id, exec.agent)
+ const body = read.text.length > 0 ? read.text : '(no new output)'
+ const separator = body.endsWith('\n') ? '' : '\n'
+ return [{ type: 'text', text: `${body}${separator}${statusLine(read.snapshot)}` }]
+ },
+ presentCall: args => presentTaskCall(`Read output from background task ${args.task_id}`, 'read', args.task_id),
+ }))
+
+ ctx.tools.register(defineTool({
+ name: 'task_list',
+ description: 'List your background tasks (running and finished) with their ids, kinds, and statuses.',
+ parameters: {},
+ // execute is synchronous (registry reads + string shaping) but the
+ // ToolDefinition contract wants a Promise — hence resolve(), not async.
+ execute(_args, exec) {
+ const tasks = ctx.tasks.list(exec.agent)
+ const text = tasks.length === 0
+ ? '(no background tasks)'
+ : tasks.map(t => `${t.id} [${t.kind}] ${t.status} — ${t.label}`).join('\n')
+ return Promise.resolve([{ type: 'text', text }])
+ },
+ presentCall: () => presentTaskCall('List background tasks', 'read'),
+ }))
+
+ ctx.tools.register(defineTool({
+ name: 'task_kill',
+ description: 'Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.',
+ parameters: {
+ task_id: { type: 'string', required: true, description: 'Task id returned by the tool that started the background work.' },
+ reason: { type: 'string', description: 'Optional short reason, recorded in the log and forwarded to the task.' },
+ },
+ execute(args, exec) {
+ const id = validateTaskId(args.task_id)
+ const result = ctx.tasks.kill(id, exec.agent, args.reason)
+ if (result === 'already-terminal') {
+ // ctx.tasks.get, NOT .read: a read would consume a stream task's
+ // pending delta just to describe the terminal state.
+ const snapshot = ctx.tasks.get(id, exec.agent)
+ return Promise.resolve([{ type: 'text', text: `task ${id} had already finished ${statusLine(snapshot)}` }])
+ }
+ return Promise.resolve([{ type: 'text', text: `requested cancellation of task ${id}` }])
+ },
+ presentCall: args => presentTaskCall(`Kill background task ${args.task_id}`, 'execute', args.task_id),
+ }))
+}
diff --git a/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts
new file mode 100644
index 0000000000..930dc2b019
--- /dev/null
+++ b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts
@@ -0,0 +1,306 @@
+import { describe, expect, it, vi } from 'vitest'
+import { Context } from 'cordis'
+import { CallId } from '@deepseek-ai/dsh-llm'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import ToolRegistry from '@deepseek-ai/dsh-tools'
+import AgentRegistry from '@deepseek-ai/dsh-agent'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import TaskService from '@deepseek-ai/dsh-tasks'
+import type { TaskOutcome, TaskRegistration, TaskSnapshot } from '@deepseek-ai/dsh-tasks'
+import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
+import { statusLine } from '@deepseek-ai/dsh-tool-tasks'
+
+async function setup(config: ToolTasks.Config = {}) {
+ const ctx = new Context()
+ await ctx.plugin(SystemPrompt)
+ await ctx.plugin(ToolRegistry)
+ const agentsFiber = await ctx.plugin(AgentRegistry)
+ await ctx.plugin(TaskService)
+ const toolsFiber = await ctx.plugin(ToolTasks, config)
+ return { ctx, agentsFiber, toolsFiber }
+}
+
+/**
+ * A fake agent whose session token is `sessionId`, registered in `ctx.agents`
+ * (the notice path finds the owner by scanning the registry for a matching
+ * `session.header.id` — the agent id is deliberately DIFFERENT so a
+ * wrong-field match fails the test).
+ */
+function fakeAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void = () => {}): Agent {
+ const agent = { id: `agent-${sessionId}`, inject, session: { header: { version: 0, id: sessionId, createdAt: 0 } } } as unknown as Agent
+ ctx.agents.register(agent)
+ return agent
+}
+
+/** A controllable producer registration (settle `done` on demand, record cancels). */
+function producer(overrides: Partial = {}) {
+ let settle!: (outcome: TaskOutcome) => void
+ const cancels: (string | undefined)[] = []
+ const registration: TaskRegistration = {
+ kind: 'bash',
+ label: 'sleep 60',
+ cancel(reason) { cancels.push(reason) },
+ done: new Promise((res) => { settle = res }),
+ ...overrides,
+ }
+ return { registration, settle, cancels }
+}
+
+let callCounter = 0
+function call(ctx: Context, name: string, args: unknown, agent?: Agent) {
+ return ctx.tools.execute({ callId: CallId(`call-${++callCounter}`), name, arguments: args, ...agent ? { agent } : {} })
+}
+
+function text(result: { content: { type: string; text?: string }[] }): string {
+ return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
+}
+
+const tick = () => new Promise(r => setTimeout(r, 0))
+
+describe('tool-tasks setup', () => {
+ it('attaches the control surface on load and detaches it with the fiber', async () => {
+ const { ctx, toolsFiber } = await setup()
+ expect(() => ctx.tasks.register(producer().registration)).not.toThrow()
+ await toolsFiber.dispose()
+ expect(() => ctx.tasks.register(producer().registration)).toThrow('no control surface is attached')
+ })
+
+ it('rejects a config whose default wait exceeds the cap', async () => {
+ const ctx = new Context()
+ await ctx.plugin(SystemPrompt)
+ await ctx.plugin(ToolRegistry)
+ await ctx.plugin(TaskService)
+ await expect(ctx.plugin(ToolTasks, { waitTimeoutMs: 100, maxWaitTimeoutMs: 50 }))
+ .rejects.toThrow('waitTimeoutMs (100) exceeds maxWaitTimeoutMs (50)')
+ })
+
+ it('renders status lines with and without producer detail', () => {
+ const base = { id: 'bash-1', kind: 'bash', label: 'x', startedAt: 0, reported: false } as unknown as TaskSnapshot
+ expect(statusLine({ ...base, status: 'running' })).toBe('[status: running]')
+ expect(statusLine({ ...base, status: 'completed', detail: 'exit code: 0' })).toBe('[status: completed, exit code: 0]')
+ })
+
+ it('applies the built-in wait bounds when apply() receives a bare config', async () => {
+ // Bypasses the schemastery defaults on purpose: apply() must stand on its
+ // own `??` fallbacks when embedded programmatically without the schema.
+ const ctx = new Context()
+ await ctx.plugin(SystemPrompt)
+ await ctx.plugin(ToolRegistry)
+ await ctx.plugin(TaskService)
+ ToolTasks.apply(ctx, {})
+ expect(ctx.tools.get('task_output')).toBeDefined()
+ expect(() => ctx.tasks.register(producer().registration)).not.toThrow()
+ })
+})
+
+describe('task_output', () => {
+ it('reads a consuming delta with a trailing status line', async () => {
+ const { ctx } = await setup()
+ const chunks = ['line one\n', '']
+ ctx.tasks.register(producer({ readOutput: () => chunks.shift() ?? '' }).registration)
+
+ // A body already ending in a newline gets no doubled separator.
+ expect(text(await call(ctx, 'task_output', { task_id: 'bash-1' }))).toBe('line one\n[status: running]')
+ expect(text(await call(ctx, 'task_output', { task_id: 'bash-1' }))).toBe('(no new output)\n[status: running]')
+ })
+
+ it('returns the final output of a settled final-output task', async () => {
+ const { ctx } = await setup()
+ const p = producer({ kind: 'subagent', label: 'research' })
+ ctx.tasks.register(p.registration)
+ expect(text(await call(ctx, 'task_output', { task_id: 'subagent-1' }))).toBe('(no new output)\n[status: running]')
+
+ p.settle({ status: 'completed', detail: 'completed', output: 'the answer' })
+ await tick()
+ expect(text(await call(ctx, 'task_output', { task_id: 'subagent-1' }))).toBe('the answer\n[status: completed, completed]')
+ })
+
+ it('wait: true blocks until settlement and reports the terminal state', async () => {
+ const { ctx } = await setup()
+ const p = producer({ kind: 'subagent', label: 'research' })
+ ctx.tasks.register(p.registration)
+
+ const pending = call(ctx, 'task_output', { task_id: 'subagent-1', wait: true })
+ p.settle({ status: 'completed', output: 'done deal' })
+ expect(text(await pending)).toBe('done deal\n[status: completed]')
+ })
+
+ it('wait: true times out against the configured cap and leaves the task alive', async () => {
+ const { ctx } = await setup({ waitTimeoutMs: 10, maxWaitTimeoutMs: 20 })
+ ctx.tasks.register(producer().registration)
+
+ // A model-supplied timeout far above the cap is clamped: this returns
+ // promptly (≤ the 20ms cap), not after ten minutes.
+ const result = await call(ctx, 'task_output', { task_id: 'bash-1', wait: true, timeout_ms: 600_000 })
+ expect(text(result)).toBe('(no new output)\n[status: running]')
+ })
+
+ it('rejects an empty or unknown task id as an errored result', async () => {
+ const { ctx } = await setup()
+ expect((await call(ctx, 'task_output', { task_id: '' })).isError).toBe(true)
+ const unknown = await call(ctx, 'task_output', { task_id: 'bash-99' })
+ expect(unknown.isError).toBe(true)
+ expect(text(unknown)).toContain('unknown task bash-99')
+ })
+})
+
+describe('task_list', () => {
+ it('lists caller-visible tasks and renders the empty case', async () => {
+ const { ctx } = await setup()
+ expect(text(await call(ctx, 'task_list', {}))).toBe('(no background tasks)')
+
+ const alice = fakeAgent(ctx, 'sess-alice')
+ ctx.tasks.register(producer({ owner: alice, label: 'pnpm test' }).registration)
+ ctx.tasks.register(producer({ kind: 'subagent', label: 'open research' }).registration)
+ const p = producer({ owner: alice, label: 'build' })
+ ctx.tasks.register(p.registration)
+ p.settle({ status: 'completed', detail: 'exit code: 0' })
+ await tick()
+
+ expect(text(await call(ctx, 'task_list', {}, alice))).toBe([
+ 'bash-1 [bash] running — pnpm test',
+ 'subagent-1 [subagent] running — open research',
+ 'bash-2 [bash] completed — build',
+ ].join('\n'))
+ // A different caller sees only the unowned task.
+ const bob = fakeAgent(ctx, 'sess-bob')
+ expect(text(await call(ctx, 'task_list', {}, bob))).toBe('subagent-1 [subagent] running — open research')
+ })
+})
+
+describe('task_kill', () => {
+ it('requests cancellation with the forwarded reason', async () => {
+ const { ctx } = await setup()
+ const p = producer()
+ ctx.tasks.register(p.registration)
+
+ const result = await call(ctx, 'task_kill', { task_id: 'bash-1', reason: 'superseded' })
+ expect(text(result)).toBe('requested cancellation of task bash-1')
+ expect(p.cancels).toEqual(['superseded'])
+ })
+
+ it('reports an already-terminal task without consuming its pending delta', async () => {
+ const { ctx } = await setup()
+ let delta = 'unread tail'
+ const p = producer({ readOutput: () => { const d = delta; delta = ''; return d } })
+ ctx.tasks.register(p.registration)
+ p.settle({ status: 'completed', detail: 'exit code: 0' })
+ await tick()
+
+ expect(text(await call(ctx, 'task_kill', { task_id: 'bash-1' })))
+ .toBe('task bash-1 had already finished [status: completed, exit code: 0]')
+ // The kill described the task via a non-consuming snapshot: the delta is intact.
+ expect(text(await call(ctx, 'task_output', { task_id: 'bash-1' }))).toBe('unread tail\n[status: completed, exit code: 0]')
+ })
+
+ it('rejects an empty task id as an errored result', async () => {
+ const { ctx } = await setup()
+ expect((await call(ctx, 'task_kill', { task_id: '' })).isError).toBe(true)
+ })
+})
+
+describe('tool-owned UI presentation (presentCall)', () => {
+ it('renders generic cards for all three control tools', async () => {
+ const { ctx } = await setup()
+ expect(ctx.tools.get('task_output')?.presentCall?.({ task_id: 'bash-1' }))
+ .toEqual({ card: 'generic', title: 'Read output from background task bash-1', kind: 'read', rawInput: 'bash-1' })
+ expect(ctx.tools.get('task_list')?.presentCall?.({}))
+ .toEqual({ card: 'generic', title: 'List background tasks', kind: 'read' })
+ expect(ctx.tools.get('task_kill')?.presentCall?.({ task_id: 'subagent-2' }))
+ .toEqual({ card: 'generic', title: 'Kill background task subagent-2', kind: 'execute', rawInput: 'subagent-2' })
+ })
+})
+
+describe('completion notices', () => {
+ it('injects a notice into the owning agent when an unreported task settles', async () => {
+ const { ctx } = await setup()
+ const inject = vi.fn()
+ const owner = fakeAgent(ctx, 'sess-1', inject)
+ const p = producer({ owner, label: 'pnpm test' })
+ ctx.tasks.register(p.registration)
+
+ p.settle({ status: 'completed', detail: 'exit code: 0' })
+ await tick()
+ expect(inject).toHaveBeenCalledTimes(1)
+ expect(inject).toHaveBeenCalledWith(
+ [{ type: 'text', text: 'background task bash-1 (bash: pnpm test) finished [status: completed, exit code: 0]. Read its output with task_output.' }],
+ { source: { kind: 'plugin', plugin: 'tool-tasks' } },
+ )
+ })
+
+ it('suppresses the notice for a task the model already killed', async () => {
+ const { ctx } = await setup()
+ const inject = vi.fn()
+ const owner = fakeAgent(ctx, 'sess-1', inject)
+ const p = producer({ owner })
+ ctx.tasks.register(p.registration)
+
+ await call(ctx, 'task_kill', { task_id: 'bash-1' }, owner)
+ p.settle({ status: 'killed' })
+ await tick()
+ expect(inject).not.toHaveBeenCalled()
+ })
+
+ it('suppresses the notice when a wait returned the terminal state', async () => {
+ const { ctx } = await setup()
+ const inject = vi.fn()
+ const owner = fakeAgent(ctx, 'sess-1', inject)
+ const p = producer({ owner, kind: 'subagent' })
+ ctx.tasks.register(p.registration)
+
+ const pending = call(ctx, 'task_output', { task_id: 'subagent-1', wait: true }, owner)
+ p.settle({ status: 'completed', output: 'answer' })
+ expect(text(await pending)).toContain('answer')
+ expect(inject).not.toHaveBeenCalled()
+ })
+
+ it('drops the notice for unowned tasks and for a disposed owner (benign race)', async () => {
+ const { ctx } = await setup()
+ // Unowned: settles with nobody to notify — nothing throws.
+ const unowned = producer()
+ ctx.tasks.register(unowned.registration)
+ unowned.settle({ status: 'completed' })
+ await tick()
+
+ // Disposed owner: inject throws the disposed message — contained.
+ const inject = vi.fn(() => { throw new Error('agent "agent-sess-1" is disposed') })
+ const owner = fakeAgent(ctx, 'sess-1', inject)
+ const p = producer({ owner })
+ ctx.tasks.register(p.registration)
+ p.settle({ status: 'completed' })
+ await tick()
+ expect(inject).toHaveBeenCalledTimes(1)
+ })
+
+ it('propagates a non-disposed inject failure (a real bug must surface)', async () => {
+ const { ctx } = await setup()
+ const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
+ const owner = fakeAgent(ctx, 'sess-1', () => { throw new Error('unexpected inject bug') })
+ const p = producer({ owner })
+ ctx.tasks.register(p.registration)
+ p.settle({ status: 'completed' })
+ await tick()
+ // The throw escapes the notice listener and is contained (logged) by the
+ // registry's per-listener containment — visible, not swallowed.
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('unexpected inject bug'))
+ })
+
+ it('drops the notice when no live agent matches and when the agent registry is gone', async () => {
+ const { ctx, agentsFiber } = await setup()
+ const inject = vi.fn()
+ const owner = fakeAgent(ctx, 'sess-1', inject)
+
+ // Owner known at registration, unregistered before settlement → no match.
+ const p1 = producer({ owner })
+ ctx.tasks.register(p1.registration)
+ // A second task whose settlement happens after the whole registry is gone.
+ const p2 = producer({ owner })
+ ctx.tasks.register(p2.registration)
+
+ await agentsFiber.dispose()
+ p1.settle({ status: 'completed' })
+ p2.settle({ status: 'failed' })
+ await tick()
+ expect(inject).not.toHaveBeenCalled()
+ })
+})
diff --git a/packages/tasks/tool-tasks/tsconfig.json b/packages/tasks/tool-tasks/tsconfig.json
new file mode 100644
index 0000000000..9e5411df25
--- /dev/null
+++ b/packages/tasks/tool-tasks/tsconfig.json
@@ -0,0 +1,33 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "lib/types"
+ },
+ "include": [
+ "src"
+ ],
+ "references": [
+ {
+ "path": "../../../vendor/cosmokit"
+ },
+ {
+ "path": "../../../vendor/cordis"
+ },
+ {
+ "path": "../../../vendor/schemastery"
+ },
+ {
+ "path": "../../core/agent"
+ },
+ {
+ "path": "../../core/system-prompt"
+ },
+ {
+ "path": "../../core/tools"
+ },
+ {
+ "path": "../tasks"
+ }
+ ]
+}
diff --git a/packages/ui/acp-agent/tests/acp-agent.spec.ts b/packages/ui/acp-agent/tests/acp-agent.spec.ts
index 7e1de1226f..1dc62218bf 100644
--- a/packages/ui/acp-agent/tests/acp-agent.spec.ts
+++ b/packages/ui/acp-agent/tests/acp-agent.spec.ts
@@ -70,7 +70,9 @@ describe('dsh-acp-agent composition', () => {
})
}
const assembly = await ctx.get('systemPrompt')!.assemble()
- expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha'])
+ // The rest-slot is lexicographic: the bundle's own task control tools
+ // (tool-tasks needs no executor, unlike the pending bash tool) follow alpha.
+ expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'task_kill', 'task_list', 'task_output'])
await ctx.fiber.dispose()
})
diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md
index 60363566b7..863bddee9c 100644
--- a/packages/ui/acp/README.md
+++ b/packages/ui/acp/README.md
@@ -35,7 +35,7 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name:
The bridge multiplexes N sessions over one connection. Live sessions are held in a `Map` (forward) with a `WeakMap` reverse map so `agent/*` events — which carry only the `Agent` — demux in O(1). Every `session/event` and `agent/status` is routed strictly to its owning record, so concurrent sessions never cross-settle or interleave their `session/update` notifications. State is per session: one in-flight prompt each, `session/cancel` aborts and settles only its own agent/prompt, and disposal drains every live session in parallel to quiescence. (Per-session *permission* ownership is reserved for the deferred permission gate — `TODO(rfc010-permission-gate)`.)
-Background-task isolation rides on `dsh-tool-bash`: bash task ids are global and predictable, so each task carries an opaque owner token — the owning agent's `session.header.id` — stored on the task inside the executor (`dsh-bash`'s `ownerOf(id)` seam). `bash_output`/`bash_kill` reject a task whose token differs from the caller's session token, so one session's agent can't read or kill another's task. Ownership is by session TOKEN, not `Agent` object identity — a different `Agent` object on the same session may access the task — and because the token lives on the executor's task it survives a `tool-bash` HMR reload.
+Background-task isolation rides on the `ctx.tasks` runtime (`dsh-tasks`): task ids are global and predictable, so every read/kill/wait/list is fenced to the owning agent's session (`session.header.id`), and one session's agent can't read or kill another's task through `task_output`/`task_kill`. Ownership is by session token, not `Agent` object identity — a different `Agent` object on the same session may access the task — and because the registration lives in the tasks service it survives a producer-plugin HMR reload.
## Per-session cwd
diff --git a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts
index 34ba201040..71ce537c68 100644
--- a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts
+++ b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts
@@ -92,7 +92,9 @@ describe('dsh-stdio-agent app', () => {
})
}
const assembly = await ctx.get('systemPrompt')!.assemble()
- expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha'])
+ // The rest-slot is lexicographic: the bundle's own task control tools
+ // (tool-tasks needs no executor, unlike the pending bash tool) follow alpha.
+ expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'task_kill', 'task_list', 'task_output'])
await ctx.fiber.dispose()
})
diff --git a/packages/util/README.md b/packages/util/README.md
index ae73c8125f..886de2290e 100644
--- a/packages/util/README.md
+++ b/packages/util/README.md
@@ -6,4 +6,4 @@ Zero-dependency primitives shared across the other groups. A package lands here
|---|---|
| `brand/` | The type-only `Branded` nominal-typing primitive (no runtime code, no harness deps) |
-`dsh-brand` is the canonical case: it owns ONLY the `Branded` helper, so a capability package can brand the ids it owns (`dsh-bash`'s `BashTaskId`/`OwnerToken`, `dsh-session`'s `SessionId`, …) by depending on `dsh-brand` alone, without pulling in an unrelated package just to reach `Branded`.
+`dsh-brand` is the canonical case: it owns ONLY the `Branded` helper, so a capability package can brand the ids it owns (`dsh-tasks`'s `TaskId`, `dsh-session`'s `SessionId`, …) by depending on `dsh-brand` alone, without pulling in an unrelated package just to reach `Branded`.
diff --git a/packages/util/brand/README.md b/packages/util/brand/README.md
index 8f7943def7..41cdb8f774 100644
--- a/packages/util/brand/README.md
+++ b/packages/util/brand/README.md
@@ -21,6 +21,6 @@ Construction goes through the per-id factory in the OWNING package (a plain cast
## Policy: brand ids that cross package boundaries
-A package brands the ids it OWNS — `CallId` in `dsh-llm` (tool-call correlation), `SessionId` in `dsh-session`, `AgentId` in `dsh-agent`, `BashTaskId`/`OwnerToken` in `dsh-bash`. Branding is for ids that cross package boundaries and could plausibly be confused; **not every string needs a brand.**
+A package brands the ids it OWNS — `CallId` in `dsh-llm` (tool-call correlation), `SessionId` in `dsh-session`, `AgentId` in `dsh-agent`, `TaskId` in `dsh-tasks`. Branding is for ids that cross package boundaries and could plausibly be confused; **not every string needs a brand.**
-This package owns ONLY the primitive — no concrete id, no runtime code beyond the (erased) type. Keeping the primitive dependency-free is the point: a capability package can brand its ids without depending on an unrelated package. `dsh-bash`, for example, brands `BashTaskId`/`OwnerToken` by depending on `dsh-brand` alone — it never pulls in `dsh-llm` (or `dsh-session`) just to reach `Branded`.
+This package owns ONLY the primitive — no concrete id, no runtime code beyond the (erased) type. Keeping the primitive dependency-free is the point: a capability package can brand its ids without depending on an unrelated package. `dsh-tasks`, for example, brands `TaskId` by depending on `dsh-brand` alone — it never pulls in `dsh-llm` (or `dsh-session`) just to reach `Branded`.
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 32ffa0d389..59294e9fe4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -77,9 +77,6 @@ importers:
packages/bash/bash:
devDependencies:
- '@deepseek-ai/dsh-brand':
- specifier: workspace:^
- version: link:../../util/brand
cordis:
specifier: ^4.0.0-rc.6
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
@@ -98,6 +95,10 @@ importers:
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
packages/bash/tool-bash:
+ dependencies:
+ schemastery:
+ specifier: ^3.18.0
+ version: 3.18.0
devDependencies:
'@deepseek-ai/dsh-agent':
specifier: workspace:^
@@ -120,6 +121,12 @@ importers:
'@deepseek-ai/dsh-system-prompt':
specifier: workspace:^
version: link:../../core/system-prompt
+ '@deepseek-ai/dsh-tasks':
+ specifier: workspace:^
+ version: link:../../tasks/tasks
+ '@deepseek-ai/dsh-tool-tasks':
+ specifier: workspace:^
+ version: link:../../tasks/tool-tasks
'@deepseek-ai/dsh-tools':
specifier: workspace:^
version: link:../../core/tools
@@ -220,9 +227,15 @@ importers:
'@deepseek-ai/dsh-system-prompt':
specifier: workspace:^
version: link:../system-prompt
+ '@deepseek-ai/dsh-tasks':
+ specifier: workspace:^
+ version: link:../../tasks/tasks
'@deepseek-ai/dsh-tool-bash':
specifier: workspace:^
version: link:../../bash/tool-bash
+ '@deepseek-ai/dsh-tool-tasks':
+ specifier: workspace:^
+ version: link:../../tasks/tool-tasks
'@deepseek-ai/dsh-tools':
specifier: workspace:^
version: link:../tools
@@ -747,6 +760,12 @@ importers:
'@deepseek-ai/dsh-system-prompt':
specifier: workspace:^
version: link:../../core/system-prompt
+ '@deepseek-ai/dsh-tasks':
+ specifier: workspace:^
+ version: link:../../tasks/tasks
+ '@deepseek-ai/dsh-tool-tasks':
+ specifier: workspace:^
+ version: link:../../tasks/tool-tasks
'@deepseek-ai/dsh-tools':
specifier: workspace:^
version: link:../../core/tools
@@ -819,6 +838,49 @@ importers:
specifier: ^4.0.0-rc.6
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
+ packages/tasks/tasks:
+ devDependencies:
+ '@deepseek-ai/dsh-agent':
+ specifier: workspace:^
+ version: link:../../core/agent
+ '@deepseek-ai/dsh-brand':
+ specifier: workspace:^
+ version: link:../../util/brand
+ '@deepseek-ai/dsh-session':
+ specifier: workspace:^
+ version: link:../../core/session
+ cordis:
+ specifier: ^4.0.0-rc.6
+ version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
+
+ packages/tasks/tool-tasks:
+ dependencies:
+ schemastery:
+ specifier: ^3.18.0
+ version: 3.18.0
+ devDependencies:
+ '@deepseek-ai/dsh-agent':
+ specifier: workspace:^
+ version: link:../../core/agent
+ '@deepseek-ai/dsh-llm':
+ specifier: workspace:^
+ version: link:../../llm/llm
+ '@deepseek-ai/dsh-session':
+ specifier: workspace:^
+ version: link:../../core/session
+ '@deepseek-ai/dsh-system-prompt':
+ specifier: workspace:^
+ version: link:../../core/system-prompt
+ '@deepseek-ai/dsh-tasks':
+ specifier: workspace:^
+ version: link:../tasks
+ '@deepseek-ai/dsh-tools':
+ specifier: workspace:^
+ version: link:../../core/tools
+ cordis:
+ specifier: ^4.0.0-rc.6
+ version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
+
packages/todo/tool-todo:
devDependencies:
'@deepseek-ai/dsh-agent':
diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json
index fc2b9d12c2..76fdd4e648 100644
--- a/scripts/doc-budgets.manifest.json
+++ b/scripts/doc-budgets.manifest.json
@@ -1,11 +1,11 @@
{
"AGENTS.md": 1691,
"docs/AGENTS.md": 1315,
- "docs/architecture.md": 1640,
+ "docs/architecture.md": 1760,
"docs/cordis-primer.md": 550,
"docs/defensive-patterns.md": 550,
"docs/testing.md": 800,
"examples/AGENTS.md": 610,
"packages/AGENTS.md": 450,
- "packages/README.md": 605
+ "packages/README.md": 645
}
diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts
index 6440a672be..d0bc987fdc 100644
--- a/scripts/gen-doc-graphs.ts
+++ b/scripts/gen-doc-graphs.ts
@@ -73,6 +73,7 @@ const GROUP_ORDER = [
'fs',
'compact',
'subagent',
+ 'tasks',
'web',
'todo',
'hooks',
@@ -186,6 +187,14 @@ const SERVICE_ROLES: ServiceRole[] = [
consumers: ['tool-subagent'],
note: 'Providers implement transports; tool-subagent exposes one configured provider as a model-facing tool name.',
},
+ {
+ key: 'tasks',
+ pkg: 'tasks',
+ title: 'Background task registry',
+ mode: 'core',
+ consumers: ['tool-bash', 'tool-subagent', 'tool-tasks'],
+ note: 'Producers (tool-bash background commands, tool-subagent background delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it.',
+ },
{
key: 'web',
pkg: 'web',
diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts
index 24739e1388..ef933fc325 100644
--- a/scripts/gen-tool-catalog.ts
+++ b/scripts/gen-tool-catalog.ts
@@ -46,8 +46,10 @@ import * as WebSearchExa from '@deepseek-ai/dsh-web-search-exa'
import * as WebFetchLocal from '@deepseek-ai/dsh-web-fetch-local'
import SubagentService from '@deepseek-ai/dsh-subagent'
import * as SubagentMock from '@deepseek-ai/dsh-subagent-mock'
+import TaskService from '@deepseek-ai/dsh-tasks'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
+import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent'
import * as ToolWeb from '@deepseek-ai/dsh-tool-web'
@@ -104,14 +106,14 @@ const TOOL_PACKAGES: ToolPackage[] = [
pkg: '@deepseek-ai/dsh-tool-bash',
dir: 'tool-bash',
source: 'packages/bash/tool-bash/src/index.ts',
- requires: ['ctx.tools', 'ctx.bash'],
- writes: ['tool/call', 'tool/result', 'context/message via agent.inject() for background completion notices'],
+ 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(LocalBashExecutor)
await ctx.plugin(ToolBash)
},
note:
- 'The bash/bash_output/bash_kill tools are model-facing consumers of the bash executor seam.',
+ 'The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled.',
},
{
pkg: '@deepseek-ai/dsh-tool-fs',
@@ -145,6 +147,19 @@ const TOOL_PACKAGES: ToolPackage[] = [
note:
'The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/coding-agent/cordis.yml` and `examples/acp-agent/cordis.yml`.',
},
+ {
+ pkg: '@deepseek-ai/dsh-tool-tasks',
+ dir: 'tool-tasks',
+ source: 'packages/tasks/tool-tasks/src/index.ts',
+ requires: ['ctx.tools', 'ctx.tasks', 'ctx.systemPrompt'],
+ writes: ['tool/call', 'tool/result', 'context/message via agent.inject() for background completion notices'],
+ async mount(ctx) {
+ await ctx.plugin(TaskService)
+ await ctx.plugin(ToolTasks)
+ },
+ note:
+ 'The kind-agnostic background-task control surface: a background bash command and a background subagent are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers\' `ctx.tasks.register()`.',
+ },
{
pkg: '@deepseek-ai/dsh-tool-todo',
dir: 'tool-todo',
diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json
index 613280ee1b..b4e19c8f13 100644
--- a/scripts/type-equiv.manifest.json
+++ b/scripts/type-equiv.manifest.json
@@ -52,8 +52,13 @@
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecSpec", "source": "packages/bash/bash/src/types.ts" },
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashRunResult", "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": "BashTask", "source": "packages/bash/bash/src/types.ts" },
- { "doc": "docs/core-data-structures/bash.md", "symbol": "BashTaskRead", "source": "packages/bash/bash/src/types.ts" },
+ { "doc": "docs/core-data-structures/bash.md", "symbol": "BashProcess", "source": "packages/bash/bash/src/types.ts" },
+ { "doc": "docs/core-data-structures/bash.md", "symbol": "BashProcessRead", "source": "packages/bash/bash/src/types.ts" },
+
+ { "doc": "docs/core-data-structures/tasks.md", "symbol": "TaskRegistration", "source": "packages/tasks/tasks/src/types.ts" },
+ { "doc": "docs/core-data-structures/tasks.md", "symbol": "TaskOutcome", "source": "packages/tasks/tasks/src/types.ts" },
+ { "doc": "docs/core-data-structures/tasks.md", "symbol": "TaskSnapshot", "source": "packages/tasks/tasks/src/types.ts" },
+ { "doc": "docs/core-data-structures/tasks.md", "symbol": "TaskRead", "source": "packages/tasks/tasks/src/types.ts" },
{ "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeRunRequest", "source": "packages/code-runtime/code-runtime/src/types.ts" },
{ "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeRunResult", "source": "packages/code-runtime/code-runtime/src/types.ts" },
diff --git a/tsconfig.base.json b/tsconfig.base.json
index ea1d804ba5..29d71d6560 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -47,6 +47,7 @@
"./packages/fs/*/src",
"./packages/compact/*/src",
"./packages/subagent/*/src",
+ "./packages/tasks/*/src",
"./packages/web/*/src",
"./packages/todo/*/src",
"./packages/hooks/*/src",
diff --git a/tsconfig.build.json b/tsconfig.build.json
index 3d99ad4e28..fb1b8d199d 100644
--- a/tsconfig.build.json
+++ b/tsconfig.build.json
@@ -53,6 +53,8 @@
{ "path": "./packages/subagent/subagent-spawn" },
{ "path": "./packages/subagent/subagent-fork" },
{ "path": "./packages/subagent/subagent-acp" },
+ { "path": "./packages/tasks/tasks" },
+ { "path": "./packages/tasks/tool-tasks" },
{ "path": "./packages/todo/tool-todo" },
{ "path": "./packages/hooks/hook-protocol" },
{ "path": "./packages/hooks/hooks-claude" },
diff --git a/tsconfig.json b/tsconfig.json
index 2091283c93..c60cf63458 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -64,6 +64,8 @@
{ "path": "./packages/subagent/subagent-spawn" },
{ "path": "./packages/subagent/subagent-fork" },
{ "path": "./packages/subagent/subagent-acp" },
+ { "path": "./packages/tasks/tasks" },
+ { "path": "./packages/tasks/tool-tasks" },
{ "path": "./packages/todo/tool-todo" },
{ "path": "./packages/hooks/hook-protocol" },
{ "path": "./packages/hooks/hooks-claude" },