diff --git a/docs/capability-seams.md b/docs/capability-seams.md
index 2780299d0e..9c650c9df1 100644
--- a/docs/capability-seams.md
+++ b/docs/capability-seams.md
@@ -24,6 +24,9 @@ flowchart LR
svc_sessionPersistence["ctx.sessionPersistence
Durable session persistence seam"]
pkg_session_persistence_jsonl["session-persistence-jsonl"]
pkg_session_persistence_sqlite["session-persistence-sqlite"]
+ pkg_tool_bash["tool-bash"]
+ pkg_hooks_claude["hooks-claude"]
+ pkg_hooks_codex["hooks-codex"]
pkg_acp["acp"]
svc_sessionQuery["ctx.sessionQuery
Exact session-history reads and traces"]
pkg_system_prompt["system-prompt"]
@@ -33,7 +36,6 @@ flowchart LR
pkg_tool_web["tool-web"]
svc_tools["ctx.tools
Tool registry and guarded execution pipeline"]
pkg_tool_ask_user["tool-ask-user"]
- pkg_tool_bash["tool-bash"]
pkg_tool_cordis["tool-cordis"]
pkg_tool_skill["tool-skill"]
pkg_tool_subagent["tool-subagent"]
@@ -51,8 +53,7 @@ flowchart LR
svc_bash["ctx.bash
Bash executor seam"]
pkg_bash_local["bash-local"]
pkg_bash_sandbox["bash-sandbox"]
- pkg_hooks_claude["hooks-claude"]
- pkg_hooks_codex["hooks-codex"]
+ svc_bashEnv["ctx.bashEnv
Managed bash environment registry"]
pkg_sandbox["sandbox"]
svc_sandbox["ctx.sandbox
Process-sandbox seam"]
pkg_sandbox_local["sandbox-local"]
@@ -130,6 +131,7 @@ flowchart LR
pkg_subagent_spawn --> svc_subagents
pkg_system_prompt --> svc_systemPrompt
pkg_tasks --> svc_tasks
+ pkg_tool_bash --> svc_bashEnv
pkg_tools --> svc_tools
pkg_user_interaction --> svc_userInteraction
pkg_web --> svc_web
@@ -159,7 +161,10 @@ flowchart LR
svc_sandbox --> pkg_bash_sandbox
svc_sessionPersistence --> pkg_acp
svc_sessionPersistence --> pkg_agent_loop
+ svc_sessionPersistence --> pkg_hooks_claude
+ svc_sessionPersistence --> pkg_hooks_codex
svc_sessionPersistence --> pkg_session_query
+ svc_sessionPersistence --> pkg_tool_bash
svc_sessions --> pkg_agent
svc_sessions --> pkg_agent_loop
svc_sessions --> pkg_invariants
@@ -198,7 +203,7 @@ flowchart LR
| --- | --- | --- | --- | --- | --- | --- |
| `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compact-basic`](../packages/compact/compact-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. |
| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. |
-| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`session-query`](../packages/session-query/session-query) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. |
+| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`acp`](../packages/ui/acp), [`session-query`](../packages/session-query/session-query) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. |
| `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | - | - | - | Resolves live and optional persisted logs into one logical corpus for exact reads and relationship traces. |
| `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. |
| `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/cordis/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web), [`acp`](../packages/ui/acp) | - | Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation. |
@@ -207,6 +212,7 @@ flowchart LR
| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`stdio-demo`](../packages/examples/stdio-demo), [`invariants`](../packages/support/invariants) | - | Owns live Agent handles and the create/resume factory seam. |
| `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. |
| `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them. |
+| `ctx.bashEnv` | `core` | [`tool-bash`](../packages/bash/tool-bash) | - | - | - | Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace. |
| `ctx.sandbox` | `seam` | [`sandbox`](../packages/sandbox/sandbox) | [`sandbox-local`](../packages/sandbox/sandbox-local) | [`bash-sandbox`](../packages/bash/bash-sandbox) | - | Consumers hand over the exact argv they are about to spawn; same-world backends wrap it under a per-call policy and report enforcement. |
| `ctx.approval` | `seam` | `approval` | [`acp`](../packages/ui/acp) | [`tools`](../packages/core/tools), [`tool-bash`](../packages/bash/tool-bash) | - | One-shot permission decisions dispatched over the `approval/request` waterfall; answerers are listeners (the ACP bridge for its own agents), absence fails closed to `unavailable`. |
| `ctx.permission` | `core` | [`permission`](../packages/ui/permission) | - | [`acp`](../packages/ui/acp) | - | User-facing preset table (`workspace-write`/`danger-full-access`) bundling the sandbox-mode and approval-policy knobs; a switch writes one `permission/preset` event through to both knob events. |
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index af01b7de9e..933ca9127d 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -48,6 +48,8 @@ export interface Config {
toolOrder?: string[]
/** Tool-registry config — its presentation `mode` (forwarded through agent-spine-demo; see dsh-tools). */
tools?: ToolsConfig
+ /** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */
+ dshHome?: string
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
persistenceRoot?: string
/** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */
@@ -97,13 +99,14 @@ Source: [`packages/core/agent-loop/src/index.ts:322`](../packages/core/agent-loo
* bridge, simply omits it), `persona` and `toolOrder` to the system-prompt
* plugin (the deployment's persona section and the explicit model-facing tool
* order), the `tools` object to the tool registry (its presentation `mode`),
- * `skills` to the skill registry/local provider/tool consumer,
- * `workspaceContext` to the workspace-context loader, and
- * `toolBash`/`toolTasks` to the model-facing tool plugins this bundle owns.
- * Owner schemas supply defaults for optional input; workspace context instead
- * requires an explicit byte budget or `false` because it changes model-visible
- * input. Producer opt-in stays producer-local: `toolBash` configures bash only;
- * independently composed producers keep their own config.
+ * `dshHome` to bash environment and local skill discovery, `skills` to the
+ * skill registry/local provider/tool consumer, `workspaceContext` to the
+ * workspace-context loader, and `toolBash`/`toolTasks` to the model-facing tool
+ * plugins this bundle owns. Owner schemas supply defaults for optional input;
+ * workspace context instead requires an explicit byte budget or `false` because
+ * it changes model-visible input. Producer opt-in stays producer-local:
+ * `toolBash` configures bash only; independently composed producers keep their
+ * own config.
*/
export interface Config {
/** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */
@@ -114,6 +117,8 @@ export interface Config {
toolOrder?: SystemPromptConfig['toolOrder']
/** The tool registry's config — its presentation `mode` (see dsh-tools' `Config`). */
tools?: ToolsConfig
+ /** DeepSeek Harness home directory shared by shell context and local skill discovery. */
+ dshHome?: string
/** Workspace-context loader controls with an explicit byte budget; set `false` for hermetic prompts. */
workspaceContext: workspaceContext.Config | false
/** Skill registry, local provider, and model-facing consumer config. */
@@ -137,7 +142,7 @@ export interface SkillConfig {
Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) · [`workspaceContext`](../packages/context/workspace-context/src/index.ts)
-Source: [`packages/examples/agent-spine-demo/src/index.ts:55`](../packages/examples/agent-spine-demo/src/index.ts)
+Source: [`packages/examples/agent-spine-demo/src/index.ts:57`](../packages/examples/agent-spine-demo/src/index.ts)
## `@deepseek-ai/dsh-bash-local`
@@ -309,7 +314,7 @@ export interface Config {
}
```
-Source: [`packages/hooks/hooks-claude/src/index.ts:43`](../packages/hooks/hooks-claude/src/index.ts)
+Source: [`packages/hooks/hooks-claude/src/index.ts:44`](../packages/hooks/hooks-claude/src/index.ts)
## `@deepseek-ai/dsh-hooks-codex`
@@ -334,7 +339,7 @@ export interface Config {
}
```
-Source: [`packages/hooks/hooks-codex/src/index.ts:41`](../packages/hooks/hooks-codex/src/index.ts)
+Source: [`packages/hooks/hooks-codex/src/index.ts:42`](../packages/hooks/hooks-codex/src/index.ts)
## `@deepseek-ai/dsh-jsonrpc`
@@ -595,7 +600,7 @@ export interface Config {
}
```
-Source: [`packages/session-persistence/session-persistence-jsonl/src/index.ts:23`](../packages/session-persistence/session-persistence-jsonl/src/index.ts)
+Source: [`packages/session-persistence/session-persistence-jsonl/src/index.ts:24`](../packages/session-persistence/session-persistence-jsonl/src/index.ts)
## `@deepseek-ai/dsh-session-persistence-sqlite`
@@ -630,7 +635,7 @@ export interface Config {
export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
```
-Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:38`](../packages/session-persistence/session-persistence-sqlite/src/index.ts)
+Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:39`](../packages/session-persistence/session-persistence-sqlite/src/index.ts)
## `@deepseek-ai/dsh-session-query`
@@ -674,7 +679,7 @@ export interface Config {
}
```
-Source: [`packages/skill/skill-local/src/index.ts:39`](../packages/skill/skill-local/src/index.ts)
+Source: [`packages/skill/skill-local/src/index.ts:40`](../packages/skill/skill-local/src/index.ts)
## `@deepseek-ai/dsh-spill-local`
@@ -748,6 +753,8 @@ export interface Config {
toolOrder?: string[]
/** Tool-registry config — its presentation `mode` (forwarded through agent-spine-demo; see dsh-tools). */
tools?: ToolsConfig
+ /** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */
+ dshHome?: string
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
persistenceRoot?: string
/** stdin-chat banner printed once on start. Defaults to `'ready.'`. */
@@ -925,14 +932,16 @@ Source: [`packages/context/time-context/src/index.ts:22`](../packages/context/ti
Requires: `tools` · `bash` · `systemPrompt`
```ts config-catalog
-/** Configures whether the model may background commands. */
+/** Configuration for the bash tool and its managed child environment. */
export interface Config {
/** Expose `run_in_background` (default true); disabled calls are also rejected. */
enableRunInBackground?: boolean
+ /** DeepSeek Harness home directory exposed as `DSH_HOME`; defaults to `$DSH_HOME` or `~/.dsh`. */
+ dshHome?: string
}
```
-Source: [`packages/bash/tool-bash/src/index.ts:30`](../packages/bash/tool-bash/src/index.ts)
+Source: [`packages/bash/tool-bash/src/index.ts:39`](../packages/bash/tool-bash/src/index.ts)
## `@deepseek-ai/dsh-tool-cordis`
@@ -1365,6 +1374,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
- `@deepseek-ai/dsh-app-boot` ([`packages/ui/app-boot/src/index.ts`](../packages/ui/app-boot/src/index.ts))
- `@deepseek-ai/dsh-brand` ([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts))
- `@deepseek-ai/dsh-helper` ([`packages/sdk/helper/src/index.ts`](../packages/sdk/helper/src/index.ts))
+- `@deepseek-ai/dsh-home` ([`packages/util/home/src/index.ts`](../packages/util/home/src/index.ts))
- `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts))
- `@deepseek-ai/dsh-jsonrpc-demo` ([`packages/examples/jsonrpc-demo/src/index.ts`](../packages/examples/jsonrpc-demo/src/index.ts))
- `@deepseek-ai/dsh-loader-smoke` ([`packages/support/loader-smoke/src/index.ts`](../packages/support/loader-smoke/src/index.ts))
diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md
index 460e998b74..4e66d723cd 100644
--- a/docs/cordis-catalog/services.md
+++ b/docs/cordis-catalog/services.md
@@ -71,7 +71,21 @@ abstract start(spec: BashExecSpec): BashProcess
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:46`](../../packages/bash/bash/src/index.ts)
+Source: [`packages/bash/bash/src/index.ts:49`](../../packages/bash/bash/src/index.ts)
+
+## `ctx.bashEnv` — `BashEnvRegistry`
+
+Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables. The namespace is rebuilt for every model bash call: ambient `DSH_*` values are discarded by the executor, then the registry's current snapshot is injected. Built-in shell facts remain owned by the registry itself while plugins can register additional, enumerable facts with effect-scoped disposal.
+
+```ts cordis-catalog
+register(contributor: BashEnvContributor): () => void
+collect(execution: ToolExecution): DshEnvironment
+list(): BashEnvVariableInfo[]
+```
+
+Types: [ToolExecution](../core-data-structures/tools.md)
+
+Source: [`packages/bash/tool-bash/src/index.ts:102`](../../packages/bash/tool-bash/src/index.ts)
## `ctx.codeRuntime` — `CodeRuntime` (abstract seam)
@@ -163,6 +177,7 @@ Source: [`packages/sandbox/sandbox/src/index.ts:111`](../../packages/sandbox/san
Durable append-only session storage. Implementations preserve contiguous, losslessly JSON-serializable events; append resolves only after durability, and load balances a complete interrupted tail without rewriting committed events.
```ts cordis-catalog
+abstract locate(meta: SessionHeader): SessionLocation | undefined
abstract create(meta: SessionHeader): Promise
abstract append(id: SessionId, events: readonly SessionEvent[]): Promise
abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }>
@@ -171,7 +186,7 @@ abstract list(): Promise
Types: [SessionEvent](../core-data-structures/core.md)
-Source: [`packages/session-persistence/session-persistence/src/index.ts:30`](../../packages/session-persistence/session-persistence/src/index.ts)
+Source: [`packages/session-persistence/session-persistence/src/index.ts:42`](../../packages/session-persistence/session-persistence/src/index.ts)
## `ctx.sessionQuery` — `SessionQueryService`
diff --git a/docs/core-data-structures/bash.md b/docs/core-data-structures/bash.md
index a8096c3b5c..c61c24add6 100644
--- a/docs/core-data-structures/bash.md
+++ b/docs/core-data-structures/bash.md
@@ -4,6 +4,18 @@ The bash execution seam is split across interface ([dsh-bash](../../packages/bas
Source: [`packages/bash/bash/src/types.ts`](../../packages/bash/bash/src/types.ts)
+## Managed shell environment namespace
+
+`DSH_*` variables are Harness-owned child-process facts. The model-facing bash tool collects them through `ctx.bashEnv` and passes them through `BashExecRequest.dshEnv`; executors remove inherited `DSH_*` names before merging the current snapshot.
+
+```ts type-equiv
+type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}`
+```
+
+```ts type-equiv
+type DshEnvironment = Readonly>
+```
+
## Request vs. spec: the `resolve()` split
The seam separates the **model-/plugin-facing request** (optional `workdir`/`timeoutMs`/`stdoutMaxBytes`, filled from config or request policy) from the **fully-resolved spec** the executor acts on (those fields required). The tool layer calls `ctx.bash.resolve(request)` between them — this is the repo's "explicit > implicit at package seams" rule made concrete: the reader of a `BashExecSpec` never wonders where the working directory or output budget came from.
@@ -33,15 +45,20 @@ interface BashExecRequest {
*/
stdin?: string | undefined
/**
- * Extra environment entries for the command, merged AFTER the
- * implementation's credential scrub (so an explicit entry here is honored even
- * when its name matches the scrub pattern — the caller named a value it holds,
- * not the harness's ambient secret). Set by in-process plugins (the hooks
- * bridges set `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the model-facing
- * bash tool does not expose it as a parameter (a model that needs an env var
- * uses shell syntax like `FOO=bar cmd`).
+ * Ordinary environment entries for the command, merged after the credential
+ * scrub. `DSH_*` is reserved for {@link dshEnv} and implementations reject it
+ * here. Set by in-process plugins (the hooks bridges set
+ * `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the model-facing bash tool
+ * does not expose it as a parameter.
*/
env?: Record | undefined
+ /**
+ * Harness-owned `DSH_*` variables for this execution. Executors discard
+ * ambient `DSH_*` entries before merging this snapshot, so an unavailable
+ * current fact cannot inherit a stale value from the harness process, and
+ * reject non-`DSH_*` names supplied through this managed channel.
+ */
+ dshEnv?: DshEnvironment | undefined
/**
* Explicit per-call sandbox-policy input, overriding the executor's
* configured default mode for THIS call. Never a silent default: a
@@ -85,6 +102,8 @@ interface BashExecSpec {
* config default, absent means "no extra env".
*/
env?: Record | undefined
+ /** Managed `DSH_*` snapshot; implementations reject ordinary names. */
+ dshEnv?: DshEnvironment | undefined
/**
* The sandbox mode this call executes under, required-but-nullable so every
* resolved spec states its policy. A sandboxing executor's `resolve()` stamps
diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md
index 7bf102924b..c1e02ae743 100644
--- a/docs/core-data-structures/persistence.md
+++ b/docs/core-data-structures/persistence.md
@@ -2,7 +2,7 @@
The **durability seam** for the event log. [session.md](session.md) describes the in-memory `Session` — the append-only `SessionEvent` log that is the source of truth. This page describes how that log is made durable: the abstract `SessionPersistence` service, its backends, the flush checkpoint, crash recovery, and the metadata header that travels alongside the log. The event vocabulary the log carries is enumerated, member by member, in the generated [persistence log event catalog](../persistence-catalog.md).
-The seam is a textbook [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md): one abstract service ([dsh-session-persistence](../../packages/session-persistence/session-persistence), `ctx.sessionPersistence`) defining create/append/load/list over the existing `SessionEvent` — **no parallel persisted type** — and two interchangeable backends that pass the same `runPersistenceContract` suite. See the [session-persistence RFC](../rfc/implemented/architecture/2026-06-14-session-persistence.md).
+The seam is a textbook [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md): one abstract service ([dsh-session-persistence](../../packages/session-persistence/session-persistence), `ctx.sessionPersistence`) defining locate/create/append/load/list over the existing `SessionEvent` — **no parallel persisted type** — and two interchangeable backends that pass the same `runPersistenceContract` suite. See the [session-persistence RFC](../rfc/implemented/architecture/2026-06-14-session-persistence.md).
## The flush checkpoint
@@ -12,6 +12,19 @@ The seam is a textbook [capability seam](../rfc/implemented/architecture/2026-06
A backend that reloads a log crashed mid-turn finds an open `turn/start` with no `turn/end`. It does **not** truncate — a single turn can be huge in a long-horizon task (many steps, large tool output), and those events were durably appended before the crash. Instead it closes the orphaned turn with a synthetic `turn/end { reason: { kind: 'interrupted' } }`, keeping the log balanced and the turn-enclosure invariant intact. `interrupted` is the one `TurnEndReason` no loop emits (see [session.md](session.md#why-a-turn-ended-turnendreasonmap)).
+## `SessionLocation` — optional per-session artifact target
+
+`SessionPersistence.locate(meta)` synchronously resolves a backend-owned independent artifact without reading, creating, or flushing it. JSONL returns its absolute target path; SQLite returns `undefined` because sessions share one database. A returned path can therefore name a file that does not yet exist or lacks the current unflushed turn; it is a location hint, not authorization or a freshness guarantee.
+
+```ts type-equiv
+interface SessionLocation {
+ /** Backend-specific artifact kind, for example `jsonl`. */
+ readonly kind: string
+ /** Absolute path to this session's backend-owned artifact. */
+ readonly path: string
+}
+```
+
## `SessionHeader` — metadata beside the log
Per-session metadata travels **separately** from the event log: format version, cwd, lineage, and the seed boundary are storage concerns, not conversation events, so they stay out of `SessionEventMap` and never reach `deriveMessages()`. The header is attached to a `Session` via `session.header`.
@@ -80,7 +93,7 @@ Replay/fork is therefore `ctx.sessions.create(id, { seed: seedEvents })`; resumi
## The backends
-Both implement the same abstract `SessionPersistence` (create/append/load/list over `SessionEvent`) and pass `runPersistenceContract`, proving the seam is genuinely backend-agnostic:
+Both implement the same abstract `SessionPersistence` (locate/create/append/load/list over `SessionEvent`) and pass `runPersistenceContract`, proving the seam is genuinely backend-agnostic:
- **[dsh-session-persistence-jsonl](../../packages/session-persistence/session-persistence-jsonl)** — an append-only JSONL log per session with crash-safe atomic writes, the interrupted-turn crash recovery above, and a read/replay path.
- **[dsh-session-persistence-sqlite](../../packages/session-persistence/session-persistence-sqlite)** — `node:sqlite`, one row per `SessionEvent`. The row shape `(session_id, seq, type, time, data, source_event_seqs, surface_op)` maps 1:1 onto the event, including optional surface metadata, so there is no parallel persisted schema to keep in sync.
diff --git a/docs/module-graph.md b/docs/module-graph.md
index ba67ef3f87..74973ff873 100644
--- a/docs/module-graph.md
+++ b/docs/module-graph.md
@@ -9,6 +9,7 @@ Inter-package dependencies among the `@deepseek-ai/dsh-*` harness packages, deri
flowchart TD
subgraph group_util["packages/util"]
pkg_brand["brand"]
+ pkg_home["home"]
pkg_paths["paths"]
pkg_retention["retention"]
pkg_timeout["timeout"]
@@ -172,6 +173,7 @@ flowchart TD
pkg_fs_local --> pkg_fs
pkg_fs_policy --> pkg_fs
pkg_skill_local --> pkg_fs
+ pkg_skill_local --> pkg_home
pkg_skill_local --> pkg_skill
pkg_compact --> pkg_llm
pkg_compact --> pkg_session
@@ -252,8 +254,10 @@ flowchart TD
pkg_agent_loop --> pkg_tools
pkg_tool_bash --> pkg_agent
pkg_tool_bash --> pkg_bash
+ pkg_tool_bash --> pkg_home
pkg_tool_bash --> pkg_llm
pkg_tool_bash --> pkg_sandbox
+ pkg_tool_bash --> pkg_session_persistence
pkg_tool_bash --> pkg_system_prompt
pkg_tool_bash --> pkg_tasks
pkg_tool_bash --> pkg_tools
@@ -299,6 +303,7 @@ flowchart TD
pkg_hooks_codex --> pkg_hook_protocol
pkg_hooks_codex --> pkg_llm
pkg_hooks_codex --> pkg_session
+ pkg_hooks_codex --> pkg_session_persistence
pkg_hooks_codex --> pkg_tools
pkg_agent_loop_testkit --> pkg_agent
pkg_agent_loop_testkit --> pkg_llm
@@ -356,6 +361,7 @@ flowchart TD
pkg_hooks_claude --> pkg_hook_protocol
pkg_hooks_claude --> pkg_llm
pkg_hooks_claude --> pkg_session
+ pkg_hooks_claude --> pkg_session_persistence
pkg_hooks_claude --> pkg_subagent
pkg_hooks_claude --> pkg_tools
pkg_subagent_mock --> pkg_agent
@@ -368,6 +374,7 @@ flowchart TD
pkg_jsonrpc --> pkg_subagent
pkg_agent_spine_demo --> pkg_agent
pkg_agent_spine_demo --> pkg_agent_loop
+ pkg_agent_spine_demo --> pkg_home
pkg_agent_spine_demo --> pkg_invariants
pkg_agent_spine_demo --> pkg_llm
pkg_agent_spine_demo --> pkg_session
@@ -416,6 +423,7 @@ flowchart TD
| Package | Group | Depends on |
| --- | --- | --- |
| [`brand`](../packages/util/brand) | `util` | — |
+| [`home`](../packages/util/home) | `util` | — |
| [`paths`](../packages/util/paths) | `util` | — |
| [`retention`](../packages/util/retention) | `util` | — |
| [`timeout`](../packages/util/timeout) | `util` | — |
@@ -442,7 +450,7 @@ flowchart TD
| [`bash`](../packages/bash/bash) | `bash` | [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |
| [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs) |
| [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs) |
-| [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`skill`](../packages/skill/skill) |
+| [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`home`](../packages/util/home), [`skill`](../packages/skill/skill) |
| [`compact`](../packages/compact/compact) | `compact` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`web-fetch-local`](../packages/web/web-fetch-local) | `web` | [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) |
| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`web`](../packages/web/web) |
@@ -470,7 +478,7 @@ flowchart TD
| [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) |
| [`stdio`](../packages/ui/stdio) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`user-interaction`](../packages/ui/user-interaction) |
| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`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), [`sandbox`](../packages/sandbox/sandbox), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
+| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`home`](../packages/util/home), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
| [`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) |
| [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) |
@@ -480,7 +488,7 @@ flowchart TD
| [`timeout-policy`](../packages/timeout/timeout-policy) | `timeout` | [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
| [`tool-cordis`](../packages/cordis/tool-cordis) | `cordis` | [`scope`](../packages/core/scope), [`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) |
+| [`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), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) |
| [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`permission`](../packages/ui/permission), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`user-interaction`](../packages/ui/user-interaction) |
| [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
@@ -492,10 +500,10 @@ flowchart TD
| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`subagent-subprocess`](../packages/subagent/subagent-subprocess) |
| [`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), [`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) |
+| [`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), [`session-persistence`](../packages/session-persistence/session-persistence), [`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) |
| [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
-| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) |
+| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`home`](../packages/util/home), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) |
| [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`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) |
| [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md
index 25d4a7ca73..386f2aaeeb 100644
--- a/docs/rfc/INDEX.md
+++ b/docs/rfc/INDEX.md
@@ -80,6 +80,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
| [Repeat-tool-call guard plugin](implemented/feature/2026-07-08-repeat-tool-guard.md) | 2026-07-08 |
| [The self-referential cordis toolset](implemented/feature/2026-07-08-self-referential-cordis-toolset.md) | 2026-07-08 |
| [Bash-backed grep and glob discovery tools](implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.md) | 2026-07-09 |
+| [Expose agent session identity and JSONL location to tools and hooks](implemented/feature/2026-07-10-agent-session-identity-and-log-location.md) | 2026-07-10 |
| [Exact session query service](implemented/feature/2026-07-10-session-query-service.md) | 2026-07-10 |
| [Configure subagent persona, tool visibility, and depth](implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) | 2026-07-12 |
| [Session query relationship tracing](implemented/feature/2026-07-13-session-query-tracing.md) | 2026-07-13 |
diff --git a/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md b/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md
index de6428fb64..bc91d425b6 100644
--- a/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md
+++ b/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md
@@ -14,9 +14,9 @@ Add `stdin?: string` and `env?: Record` to **both** `BashExecReq
Three deliberate choices:
-1. **The model-facing tool omits `stdin` and `env`.** Shell syntax already covers those needs, so duplicate parameters would add surface without authority separation. The tool builds requests only from declared model arguments, signal, and owner; trusted in-process callers may set the seam fields directly.
+1. **The model-facing tool omits `stdin` and `env`.** Shell syntax already covers those needs, so duplicate parameters would add surface without authority separation. The tool builds requests only from declared model arguments, signal, and owner; trusted in-process callers may set the seam fields directly. Harness-owned variables use the separate `dshEnv` channel from the [managed environment decision](../feature/2026-07-10-agent-session-identity-and-log-location.md), so ordinary `env` cannot replace them.
-2. **`env` merges AFTER the credential scrub, so an explicit caller entry always wins** — even a credential-shaped name. This is correct because the scrub's job is narrow: stop the harness's *ambient* `process.env` credentials from leaking into a spawned command. A caller that explicitly sets a var has named a value it already holds (not the ambient secret), so the scrub is not a constraint on it. `childEnv(extra?)` layers `scrub(process.env)` → `ENV_OVERRIDES` (the model-friendly `TERM=dumb` etc.) → `extra`, last-wins.
+2. **`env` merges AFTER the credential scrub, so an explicit caller entry wins even on a credential-shaped name.** The later managed-namespace decision reserves `DSH_*`: ambient entries are removed, ordinary `env` cannot set them, and trusted `dshEnv` merges last. The complete order is `scrub(process.env, including DSH_*)` → `ENV_OVERRIDES` → ordinary `env` → `dshEnv`.
3. **`stdin`/`env` are required-absent-OK (plain optional) on the resolved spec, NOT required-but-nullable like `owner`.** `owner` is required-but-nullable because a *silently* missing owner yields an unowned, cross-session-readable task — a security footgun that a visible `undefined` guards against. `stdin`/`env` have no such hazard: a missing one means "no stdin / no extra env", which is the safe, ordinary case (every model-driven call). So they stay plain optionals, matching `signal`.
diff --git a/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md b/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md
index e5df9c9548..af5c7e928d 100644
--- a/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md
+++ b/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md
@@ -12,8 +12,8 @@ The framing that shapes the whole design: **a bridge is a compatibility adapter,
Two independent plugins in the `packages/hooks/` group, each a function/namespace plugin (`name`/`inject`/`Config`/`apply`, NO default export — see [postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md)) injecting only `bash`:
-- **`dsh-hooks-claude`** — the CC dialect. Seven of Claude Code's current hook points: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`, `SubagentStart`, and `SubagentStop`. Owns CC-shaped per-event stdin payloads (a base of `session_id`/`cwd`/`hook_event_name` plus per-event fields), `CLAUDE_PROJECT_DIR` plus `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` substitution, and the literal-or-regex matcher mode. A CC hook's stdin carries a **trailing newline**.
-- **`dsh-hooks-codex`** — five of Codex's current hook points: `PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, and `Stop`. It uses an always-regex matcher, Codex-shaped snake_case payloads with `turn_id`/`model`/`permission_mode` extras written WITHOUT a trailing newline, no Codex plugin-env injection or config-time placeholder substitution, and no pre-tool approval or rewrite path. A tool call's payload carries the real `tool_name` in the bridge's reduced `tool_input: { command }` shape.
+- **`dsh-hooks-claude`** — the CC dialect. Seven of Claude Code's current hook points: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`, `SubagentStart`, and `SubagentStop`. Owns CC-shaped per-event stdin payloads (a base of `session_id`/`transcript_path`/`cwd`/`hook_event_name` plus per-event fields), `CLAUDE_PROJECT_DIR` plus `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` substitution, and the literal-or-regex matcher mode. `transcript_path` is the persistence locator result or `''`; stdin carries a **trailing newline**.
+- **`dsh-hooks-codex`** — five of Codex's current hook points: `PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, and `Stop`. It uses an always-regex matcher, Codex-shaped snake_case payloads with `turn_id`/`model`/`permission_mode` extras written WITHOUT a trailing newline, no Codex plugin-env injection or config-time placeholder substitution, and no pre-tool approval or rewrite path. `transcript_path` is the same locator result or `null`; tool payloads carry the real `tool_name` in the reduced `tool_input: { command }` shape.
### Outcome → Decision mapping
diff --git a/docs/rfc/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md b/docs/rfc/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md
new file mode 100644
index 0000000000..118009dedb
--- /dev/null
+++ b/docs/rfc/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md
@@ -0,0 +1,85 @@
+# RFC: Expose agent session identity and JSONL location to tools and hooks
+
+Status: implemented
+
+## Problem
+
+An agent can identify its workspace through `session.header.cwd`, but a model using bash cannot reliably identify the session that owns the call or the durable transcript that records it. Searching `./.sessions` guesses deployment config and JSONL layout; custom roots, alternate persistence backends, resume, forks, and concurrent parent/child agents make that guess unreliable. Hooks have the same need for transcript location, while future plugins may need to expose other harness-owned environment facts to shell commands.
+
+The boundary must preserve two properties: the owner of a fact decides how to resolve it, and every child receives a per-execution snapshot rather than process-global mutable state. In particular, a nested harness must not leak its ambient `DSH_*` values into a child whose current agent, persistence backend, or configuration differs.
+
+## Decision
+
+Extend the [`SessionPersistence`](../../implemented/architecture/2026-06-14-session-persistence.md) seam with a synchronous, side-effect-free location query:
+
+```ts
+import type { SessionHeader } from '@deepseek-ai/dsh-session'
+
+interface SessionLocation {
+ readonly kind: string
+ readonly path: string
+}
+
+interface SessionPersistence {
+ locate(meta: SessionHeader): SessionLocation | undefined
+}
+```
+
+`path` is an absolute local path to the backend's dedicated log for `meta`; `kind` identifies the representation. JSONL returns `{ kind: 'jsonl', path }` using its resolved root and path helpers. SQLite and any backend without an honest local per-session artifact return `undefined`. The query creates and flushes nothing, so it can report a lazy target path before that file exists.
+
+The model-facing bash package owns a `ctx.bashEnv` registry. A contributor declares its stable name, every `DSH_*` key it may return, a description for each key, and `resolve(execution: ToolExecution)`. Duplicate contributor names, duplicate key ownership, reserved keys, malformed declarations, undeclared runtime output, and non-string output fail loudly. Registration is a Cordis effect and is removed with the contributing plugin fiber. `list()` exposes declarations without running resolvers, keeping the environment surface enumerable for diagnostics and future prompt/UI consumers.
+
+The registry rebuilds a trusted overlay for every foreground and background bash `ToolExecution`:
+
+- `DSH_HOME` is always the absolute configured Harness home. The standalone [`@deepseek-ai/dsh-home`](../../../../packages/util/home/README.md) utility owns its precedence: explicit `dshHome`, then ambient `$DSH_HOME`, then `~/.dsh`.
+- `DSH_SHELL=1` is always present and identifies a model bash child managed by DeepSeek Harness.
+- `DSH_SESSION_ID` is present when the execution has an agent and equals `agent.session.header.id`.
+- The built-in persistence translator contributes `DSH_SESSION_JSONL` only when `ctx.sessionPersistence.locate(header)` returns `kind: 'jsonl'`.
+
+Session persistence remains the fact owner: JSONL does not depend on tool-bash or register shell variables itself, and hooks continue to consume `locate()` directly. Tool-bash is the translation layer from the persistence fact into a shell convention. Other plugins that need shell-visible facts depend on the registry and register their own keys; they do not modify `process.env`.
+
+The bash seam exports `DSH_ENV_PREFIX` as the single namespace source and derives `DshEnvironmentKey` from its `typeof`. Tool-bash derives built-in names and model guidance from that constant, while executors use it for filtering and channel validation. The seam carries the managed overlay separately as `BashExecRequest.dshEnv` / `BashExecSpec.dshEnv`. Ordinary `env` remains the general in-process plugin surface used by hooks, but cannot contain managed keys; symmetrically, `dshEnv` cannot contain ordinary keys. The local executor rejects either wrong channel before spawn, removes every inherited ambient managed key, applies its ordinary scrub/terminal environment/explicit `env`, and finally merges the trusted `dshEnv` snapshot. This guarantees that a missing value means absent now rather than inherited from an outer or previous harness. The model-facing tool still ignores model-supplied `env`/`stdin` arguments.
+
+The bash tool description teaches only the durable convention: current harness environment facts are available through managed `$DSH_*` variables and may be inspected when needed. It does not enumerate persistence-specific keys or add a permanent system-prompt section. Tool schemas are already logged in request headers and tool output is logged as `tool/result`, so no new session event is required.
+
+The [Claude Code and Codex hook bridges](../../implemented/feature/2026-06-30-hook-bridges.md) resolve transcript location from the same persistence seam when constructing payloads. Codex uses `transcript_path: string | null`; Claude Code preserves its string field and falls back to `''`. Hook lookup neither materializes nor flushes a session.
+
+## Peer product findings
+
+Peer products separate stable identity from physical storage. Codex injects stable `CODEX_THREAD_ID` into spawned shells while recorder and hook surfaces own transcript paths. Claude Code supplies `session_id` and `transcript_path` as structured hook/status input. OpenCode carries identity in structured tool context; Kimi Code expands a session placeholder; Reasonix keeps the active session path on its controller. The portable rule is to inject identity at the invocation boundary, let storage resolve location, and never use a process-global current-session variable in a concurrent harness.
+
+## Lifecycle and persistence semantics
+
+A fresh session receives its id before the first turn, so its first bash call can read `DSH_SESSION_ID` and a JSONL target. The JSONL file may still be absent until the first successful turn-end checkpoint, and during an open turn it contains only the last flushed prefix. `DSH_SESSION_JSONL` is a location hint, not an authorization credential or freshness guarantee.
+
+Resume reuses the loaded header and therefore the same id and location. Fork and spawn create new session ids and locations. Parent and child calls resolve from their own `ToolExecution.agent`; each command receives an immutable snapshot even when calls overlap. A persistence service replacement affects later collections because the translator queries `ctx.get('sessionPersistence')` at execution time; the registry itself is effect-scoped and HMR-safe.
+
+`dshHome` is session-independent deployment context. Agent-core resolves one value through `@deepseek-ai/dsh-home` and routes it to both tool-bash and local skill discovery; standalone consumers call the same resolver. If top-level `dshHome` and `skills.local.dshHome` are both supplied and resolve differently, composition fails instead of exposing contradictory homes. Persistence may change independently without freezing its facts into the session prefix.
+
+## Testing
+
+Unit coverage pins registry declaration validation, effect disposal, per-execution collection, the `dshHome` precedence, and the local executor's `DSH_*` scrub/rebuild order. Request-recording tests cover foreground/background snapshots, no-agent calls, absent/JSONL persistence, ignored model `env`, and parent/child isolation. JSONL/SQLite locator contract tests and both hook bridge suites pin available and unavailable transcript dialects.
+
+A keyless full-loop integration drives the real agent loop, JSONL persistence, tool-bash, and bash-local on the first turn. The child prints `DSH_HOME`, `DSH_SHELL`, session id, JSONL target, and an inherited stale sentinel; the test verifies current values, absence of the stale variable, pre-flush file absence, and the eventual persisted header. Snapshot coverage pins the generic bash description in the recorded request header. No with-key test is required because the contract is deterministic local execution rather than model choice.
+
+## Alternatives considered
+
+**Only an id plus `find`.** Search cannot know a custom root or backend layout and races under multiple sessions.
+
+**Only an absolute path.** A path can be unavailable, lazy, or representation-specific and is not stable session identity.
+
+**Global `process.env`.** Concurrent agents would overwrite one another and nested harnesses would inherit stale current-session values.
+
+**Put persistence instructions in the session prefix.** A session prefix is frozen while the active service can change across HMR or future backend switching; persistence-specific guidance would become stale.
+
+**A typed waterfall event.** Listeners cannot declare ownership without running, and later listeners can silently overwrite keys. A registry detects key conflicts at registration and remains enumerable.
+
+**Have each persistence backend register bash env directly.** That reverses the dependency from storage into one consumer and forces bash into deployments that do not use it. `locate()` is also still required by hooks.
+
+**A model-facing `session_info` tool.** It adds schema and another call while bash already supplies the query surface; the registry generalizes to future environment facts without one tool per fact.
+
+## Consequences
+
+Every model bash child receives current Harness home and shell identity, and agent calls additionally receive stable session identity. JSONL-backed calls get an optional target path; non-file persistence omits it honestly. The complete `DSH_*` namespace inside these children is managed by the harness: ambient values are removed, current trusted values are re-added, and ordinary callers cannot use `env` to bypass ownership checks.
+
+The namespace is discoverable but not secret. Paths can reveal configured roots, lazy targets can be absent or stale, and a command can override variables inside its own shell syntax. Consumers treat them as correlation and environment facts, verify transcript metadata when attribution matters, and rely on sandbox/filesystem policy rather than variable secrecy for authorization.
diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md
index 8c436f4cd6..048302db56 100644
--- a/docs/tool-catalog.md
+++ b/docs/tool-catalog.md
@@ -127,7 +127,7 @@ Owned by the tool registry as a reserved transport outside filterable capability
### `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]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. 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`.
+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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. 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
{
diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md
index 33bfa25590..9d57691ab4 100644
--- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md
+++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md
@@ -27,7 +27,7 @@ The available tools:
```ts
declare const tools: {
- /** 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]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. 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`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */
+ /** 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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. 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`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */
bash(args: {
/** The bash command to execute. */
command: string;
diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.golden.json
index daf4e93ee8..e6700c6611 100644
--- a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.golden.json
+++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.golden.json
@@ -2,7 +2,7 @@
"initial": [
{
"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]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. 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`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.",
+ "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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. 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`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.",
"parameters": {
"type": "object",
"properties": {
diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.golden.md
index 36aa94c53c..1d6918d142 100644
--- a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.golden.md
+++ b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.golden.md
@@ -27,7 +27,7 @@ The available tools:
```ts
declare const tools: {
- /** 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]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. 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`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */
+ /** 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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. 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`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */
bash(args: {
/** The bash command to execute. */
command: string;
diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.golden.json
index 52e7974409..8cc9f35ea2 100644
--- a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.golden.json
+++ b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.golden.json
@@ -2,7 +2,7 @@
"initial": [
{
"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]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. 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`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.",
+ "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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. 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`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.",
"parameters": {
"type": "object",
"properties": {
diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.golden.md
index 36aa94c53c..1d6918d142 100644
--- a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.golden.md
+++ b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.golden.md
@@ -27,7 +27,7 @@ The available tools:
```ts
declare const tools: {
- /** 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]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. 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`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */
+ /** 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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. 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`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */
bash(args: {
/** The bash command to execute. */
command: string;
diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.golden.md
index 65b354efd1..bfedf3ed80 100644
--- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.golden.md
+++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.golden.md
@@ -33,7 +33,7 @@ The available tools:
```ts
declare const tools: {
- /** 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]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. 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`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */
+ /** 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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. 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`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */
bash(args: {
/** The bash command to execute. */
command: string;
diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl
index 070f269bcd..feb791e71e 100644
--- a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl
+++ b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl
@@ -131,8 +131,8 @@
{"type":"assistant/chunk","seq":129,"time":1783962245385,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":130,"time":1783962245385,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a command with sandbox_permissions set to danger-full-access, no prior run needed, justified as instructed."},{"type":"tool-call","id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"usage":{"inputTokens":1501,"outputTokens":174,"cacheReadTokens":0,"reasoningTokens":28}},"sourceEventSeqs":[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,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129],"surfaceOp":"append"}
{"type":"tool/call","seq":131,"time":1783962245385,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}}
-{"type":"approval/asked","seq":132,"time":1783962245386,"data":{"id":"b76bc320-c954-4f8a-b7d6-30821793dae8","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}}
-{"type":"approval/decided","seq":133,"time":1783962245387,"data":{"id":"b76bc320-c954-4f8a-b7d6-30821793dae8","outcome":"allowed-once"}}
+{"type":"approval/asked","seq":132,"time":1783962245386,"data":{"id":"f2837399-691e-4913-abf4-9cd40aa31ac2","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}}
+{"type":"approval/decided","seq":133,"time":1783962245387,"data":{"id":"f2837399-691e-4913-abf4-9cd40aa31ac2","outcome":"allowed-once"}}
{"type":"tool/result","seq":134,"time":1783962245399,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","content":[{"type":"text","text":"escalated\n"}],"isError":false},"sourceEventSeqs":[131],"surfaceOp":"append"}
{"type":"step/end","seq":135,"time":1783962245400,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":136,"time":1783962245400,"data":{"turn":1,"step":2}}
diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl
index c0315ac54a..c04cfdb6c6 100644
--- a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl
+++ b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl
@@ -155,8 +155,8 @@
{"type":"assistant/chunk","seq":153,"time":1783962246274,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":154,"time":1783962246274,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a specific command with `sandbox_permissions` set to `danger-full-access` and a specific justification. They explicitly said NOT to run it without sandbox_permissions first. Let me do exactly that."},{"type":"tool-call","id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"usage":{"inputTokens":1509,"outputTokens":198,"cacheReadTokens":0,"reasoningTokens":48}},"sourceEventSeqs":[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,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153],"surfaceOp":"append"}
{"type":"tool/call","seq":155,"time":1783962246274,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}}
-{"type":"approval/asked","seq":156,"time":1783962246275,"data":{"id":"66efb593-279a-472b-b647-c50d34045bc0","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}}
-{"type":"approval/decided","seq":157,"time":1783962246275,"data":{"id":"66efb593-279a-472b-b647-c50d34045bc0","outcome":"rejected"}}
+{"type":"approval/asked","seq":156,"time":1783962246275,"data":{"id":"e5a72cf8-322c-4ec1-a082-55903876dc53","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}}
+{"type":"approval/decided","seq":157,"time":1783962246275,"data":{"id":"e5a72cf8-322c-4ec1-a082-55903876dc53","outcome":"rejected"}}
{"type":"tool/result","seq":158,"time":1783962246275,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","content":[{"type":"text","text":"Error: the user rejected escalating this command to \"danger-full-access\""}],"isError":true},"sourceEventSeqs":[155],"surfaceOp":"append"}
{"type":"step/end","seq":159,"time":1783962246276,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":160,"time":1783962246276,"data":{"turn":1,"step":2}}
diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl
index f67bf420ab..7659b738d2 100644
--- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl
+++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl
@@ -55,8 +55,8 @@
{"type":"tool/call","seq":53,"time":1783352172557,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}}
{"type":"hook/invoked","seq":54,"time":1783352172558,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}}
{"type":"hook/result","seq":55,"time":1783352172573,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"ask","exitCode":0,"durationMs":14.113374999999905}}
-{"type":"approval/asked","seq":56,"time":1783962235813,"data":{"id":"af257151-c371-4be2-a9ab-fbf4b6d18eb1","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}}
-{"type":"approval/decided","seq":57,"time":1783962235813,"data":{"id":"af257151-c371-4be2-a9ab-fbf4b6d18eb1","outcome":"rejected"}}
+{"type":"approval/asked","seq":56,"time":1783962235813,"data":{"id":"6f7aaf62-f3cf-435f-8338-e8de72dbfbbf","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}}
+{"type":"approval/decided","seq":57,"time":1783962235813,"data":{"id":"6f7aaf62-f3cf-435f-8338-e8de72dbfbbf","outcome":"rejected"}}
{"type":"tool/result","seq":58,"time":1783962235814,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","content":[{"type":"text","text":"Error: the user rejected tool \"bash\""}],"isError":true},"sourceEventSeqs":[53],"surfaceOp":"append"}
{"type":"step/end","seq":59,"time":1783962235814,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":60,"time":1783962235814,"data":{"turn":1,"step":2}}
diff --git a/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.golden.json
index 4b07d8edef..c1da2056fa 100644
--- a/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.golden.json
+++ b/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.golden.json
@@ -2,7 +2,7 @@
"initial": [
{
"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]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. 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`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.",
+ "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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. 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`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.",
"parameters": {
"type": "object",
"properties": {
diff --git a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.golden.json
index 4b07d8edef..c1da2056fa 100644
--- a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.golden.json
+++ b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.golden.json
@@ -2,7 +2,7 @@
"initial": [
{
"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]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. 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`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.",
+ "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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. 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`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.",
"parameters": {
"type": "object",
"properties": {
diff --git a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.golden.json
index 4b07d8edef..c1da2056fa 100644
--- a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.golden.json
+++ b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.golden.json
@@ -2,7 +2,7 @@
"initial": [
{
"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]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. 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`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.",
+ "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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. 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`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.",
"parameters": {
"type": "object",
"properties": {
diff --git a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.golden.json
index da5e23216c..49b1b21197 100644
--- a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.golden.json
+++ b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.golden.json
@@ -2,7 +2,7 @@
"initial": [
{
"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]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. 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`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.",
+ "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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. 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`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.",
"parameters": {
"type": "object",
"properties": {
diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.golden.json
index da5e23216c..49b1b21197 100644
--- a/examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.golden.json
+++ b/examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.golden.json
@@ -2,7 +2,7 @@
"initial": [
{
"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]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. 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`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.",
+ "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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. 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`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.",
"parameters": {
"type": "object",
"properties": {
diff --git a/knip.json b/knip.json
index 1caef3861b..58278f286b 100644
--- a/knip.json
+++ b/knip.json
@@ -35,6 +35,11 @@
"project": ["src/**/*.ts"],
"ignoreDependencies": ["cordis"]
},
+ "packages/util/home": {
+ "entry": ["tests/**/*.spec.ts"],
+ "project": ["src/**/*.ts", "tests/**/*.ts"],
+ "ignoreDependencies": ["cordis"]
+ },
"packages/util/timeout": {
"entry": ["tests/**/*.spec.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"],
diff --git a/packages/README.md b/packages/README.md
index 93a8a849d7..6c83abfc91 100644
--- a/packages/README.md
+++ b/packages/README.md
@@ -33,7 +33,7 @@ Packages live at `packages///`; groups are containers, while names r
| [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, user-approval/user-interaction seams, ask-user tool | Product — stable surface |
| [`examples/`](examples/README.md) | Demo bundles (agent-spine + stdio/ACP/JSON-RPC bins) the leaves load | Support — example infra |
| [`support/`](support/README.md) | Support infrastructure (testkits, invariants, replay, Loader smokes, subagent mock) | Support — lower compatibility expectations |
-| [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (`Branded`, path helpers, timeout, retention) | Support — small, stable, harness-dep-free |
+| [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (`Branded`, Harness home/path helpers, timeout, retention) | Support — small, stable, harness-dep-free |
Groups distinguish product API from support infrastructure. New packages join an existing group; a new group updates its README and this table.
diff --git a/packages/bash/bash-local/README.md b/packages/bash/bash-local/README.md
index 4ff043457c..afa5000767 100644
--- a/packages/bash/bash-local/README.md
+++ b/packages/bash/bash-local/README.md
@@ -24,7 +24,7 @@ Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi;
- **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` (deterministic; no rc files). All four surveyed tools spawn per call. `XXX(stateful-shell)` in `src/run.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them.
- **Process-group kills with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after the `graceMs` grace (default 3s — OpenCode's escalation; pipelines and subshells die with the parent). 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. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background tasks still use `maxOutputBytes`. 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).
+- **Model-friendly env + credential scrub** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names, then `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results. A spec's ordinary `env` is merged after the scrub but rejects `DSH_*`; managed `dshEnv` rejects ordinary names and merges last, preventing stale nested-harness identity. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment RFC](../../../docs/rfc/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
- **Background processes** — `start()` returns a live `BashProcess` handle immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), the handle's `readOutput()` is incremental with whole-stream byte offsets, and disposal kills every running process and awaits its exit. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry.
## Model Experience
diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts
index b988631b0b..587c33b9a2 100644
--- a/packages/bash/bash-local/src/index.ts
+++ b/packages/bash/bash-local/src/index.ts
@@ -100,10 +100,14 @@ export class LocalBashExecutor extends BashExecutor {
timeoutMs,
stdoutMaxBytes,
...request.signal ? { signal: request.signal } : {},
- // Explicit environment values are merged after credential scrubbing in run.ts.
+ // Carry stdin/ordinary env/trusted dshEnv through verbatim — optional,
+ // no config default. run.ts owns the scrub and merge order.
...request.stdin !== undefined ? { stdin: request.stdin } : {},
...request.env !== undefined ? { env: request.env } : {},
- // Local execution carries this override for sandboxing subclasses.
+ ...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {},
+ // Carry a sandbox-mode override through verbatim: this executor never
+ // confines, so the field is inert here (the seam contract) — a
+ // sandboxing subclass overrides resolve() to stamp its default instead.
sandboxMode: request.sandboxMode,
}
}
@@ -120,6 +124,7 @@ export class LocalBashExecutor extends BashExecutor {
signal: d.signal,
stdin: spec.stdin,
env: spec.env,
+ dshEnv: spec.dshEnv,
}, this.internals).done
// Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts.
const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined
@@ -138,6 +143,7 @@ export class LocalBashExecutor extends BashExecutor {
signal: spec.signal,
stdin: spec.stdin,
env: spec.env,
+ dshEnv: spec.dshEnv,
}, this.internals)
let stdoutOffset = 0
diff --git a/packages/bash/bash-local/src/run.ts b/packages/bash/bash-local/src/run.ts
index 5b9c49daf3..fa4dae73d6 100644
--- a/packages/bash/bash-local/src/run.ts
+++ b/packages/bash/bash-local/src/run.ts
@@ -11,7 +11,8 @@ import { randomBytes } from 'node:crypto'
import { closeSync, mkdtempSync, openSync, writeSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
-import type { CollectedOutput } from '@deepseek-ai/dsh-bash'
+import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash'
+import type { CollectedOutput, DshEnvironment } from '@deepseek-ai/dsh-bash'
/**
* Model-friendly environment overrides: disable colors, pagers, and
@@ -34,18 +35,33 @@ export const ENV_OVERRIDES = {
export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
/**
- * Build a child environment by scrubbing credential-shaped ambient variables,
- * applying model-friendly overrides, then merging trusted caller entries last.
- *
- * @param extra - caller-supplied entries merged last; an explicit entry wins even against the scrub and the overrides.
+ * Build a child environment from scrubbed ambient values, terminal overrides,
+ * ordinary caller entries, and a managed `DSH_*` snapshot. Ambient managed
+ * names are removed; ordinary and managed entries reject the other channel's
+ * namespace before `dshEnv` merges last.
+ * @param extra - caller entries; `DSH_*` names are rejected.
+ * @param dshEnv - managed entries; non-`DSH_*` names are rejected.
* @returns the environment to hand to `spawn` for the child process.
*/
-export function childEnv(extra?: Record): NodeJS.ProcessEnv {
+export function childEnv(
+ extra?: Readonly>,
+ dshEnv?: DshEnvironment,
+): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {}
for (const [key, value] of Object.entries(process.env)) {
- if (!SENSITIVE_ENV_PATTERN.test(key)) env[key] = value
+ if (!SENSITIVE_ENV_PATTERN.test(key) && !key.startsWith(DSH_ENV_PREFIX)) env[key] = value
}
- return { ...env, ...ENV_OVERRIDES, ...extra }
+ for (const key of Object.keys(extra ?? {})) {
+ if (key.startsWith(DSH_ENV_PREFIX)) {
+ throw new Error(`ordinary bash env cannot set reserved variable "${key}"; use dshEnv`)
+ }
+ }
+ for (const key of Object.keys(dshEnv ?? {})) {
+ if (!key.startsWith(DSH_ENV_PREFIX)) {
+ throw new Error(`managed bash env cannot set ordinary variable "${key}"; use env`)
+ }
+ }
+ return { ...env, ...ENV_OVERRIDES, ...extra, ...dshEnv }
}
/** What to run and under which limits (resolved — no defaults in here). */
@@ -73,12 +89,12 @@ export interface SpawnSpec {
*/
stdin?: string | undefined
/**
- * Extra environment entries, merged onto the scrubbed env AFTER the
- * credential scrub and the model-friendly overrides (so an explicit entry
- * wins). Set by in-process plugins; the model-facing tool does not forward
- * model input here.
+ * Ordinary environment entries merged after the credential scrub and
+ * terminal overrides. `DSH_*` names are rejected and belong in `dshEnv`.
*/
env?: Record | undefined
+ /** Harness-owned entries; non-`DSH_*` names are rejected before spawn. */
+ dshEnv?: DshEnvironment | undefined
}
/**
@@ -280,7 +296,7 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
}
// Keep absent stdin as /dev/null; literal tuples preserve non-null output types.
- const env = childEnv(spec.env)
+ const env = childEnv(spec.env, spec.dshEnv)
const child: ChildProcessByStdio = spec.stdin !== undefined
? spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true })
: spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['ignore', 'pipe', 'pipe'], detached: true })
diff --git a/packages/bash/bash-local/tests/executor.spec.ts b/packages/bash/bash-local/tests/executor.spec.ts
index 9aca00490c..9db0c2eebd 100644
--- a/packages/bash/bash-local/tests/executor.spec.ts
+++ b/packages/bash/bash-local/tests/executor.spec.ts
@@ -127,21 +127,28 @@ describe('LocalBashExecutor.run', () => {
await expect(bash.run(bash.resolve({ command: 'true', workdir: '/nonexistent-dsh' }))).rejects.toThrow(/ENOENT/)
})
- it('resolve() carries stdin/env onto the spec, and run() threads them to the command', async () => {
+ it('resolve() carries stdin/env/dshEnv onto the spec, and run() threads them to the command', async () => {
const { bash } = await setup()
- const spec = bash.resolve({ command: 'cat; echo "[$DSH_SEAM_VAR]"', stdin: 'piped\n', env: { DSH_SEAM_VAR: 'env-ok' } })
- // resolve() keeps the stdin/env fields verbatim (optional, no default).
+ const spec = bash.resolve({
+ command: 'cat; echo "[$SEAM_VAR][$DSH_SEAM_VAR]"',
+ stdin: 'piped\n',
+ env: { SEAM_VAR: 'env-ok' },
+ dshEnv: { DSH_SEAM_VAR: 'dsh-ok' },
+ })
+ // resolve() keeps the optional input/environment fields verbatim.
expect(spec.stdin).toBe('piped\n')
- expect(spec.env).toEqual({ DSH_SEAM_VAR: 'env-ok' })
+ expect(spec.env).toEqual({ SEAM_VAR: 'env-ok' })
+ expect(spec.dshEnv).toEqual({ DSH_SEAM_VAR: 'dsh-ok' })
const result = await bash.run(spec)
- expect(result.stdout.text).toBe('piped\n[env-ok]\n')
+ expect(result.stdout.text).toBe('piped\n[env-ok][dsh-ok]\n')
})
- it('resolve() omits stdin/env when the request supplies neither', async () => {
+ it('resolve() omits stdin/env/dshEnv when the request supplies none', async () => {
const { bash } = await setup()
const spec = bash.resolve({ command: 'true' })
expect('stdin' in spec).toBe(false)
expect('env' in spec).toBe(false)
+ expect('dshEnv' in spec).toBe(false)
})
})
@@ -160,11 +167,12 @@ describe('LocalBashExecutor.start (background process handles)', () => {
it('threads stdin and extra env into a background process', async () => {
const { bash } = await setup()
const proc = bash.start(bash.resolve({
- command: 'cat; echo "[$DSH_BG_VAR]"',
+ command: 'cat; echo "[$BG_VAR][$DSH_BG_VAR]"',
stdin: 'bg-stdin\n',
- env: { DSH_BG_VAR: 'bg-env' },
+ env: { BG_VAR: 'bg-env' },
+ dshEnv: { DSH_BG_VAR: 'bg-dsh-env' },
}))
- const output = await readUntil(proc, '[bg-env]')
+ const output = await readUntil(proc, '[bg-env][bg-dsh-env]')
expect(output).toContain('bg-stdin')
await proc.done
expect(proc.exitCode).toBe(0)
diff --git a/packages/bash/bash-local/tests/run.spec.ts b/packages/bash/bash-local/tests/run.spec.ts
index 02668ada3e..e65500a4b5 100644
--- a/packages/bash/bash-local/tests/run.spec.ts
+++ b/packages/bash/bash-local/tests/run.spec.ts
@@ -2,6 +2,7 @@ import { mkdtempSync, readFileSync, statSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
+import type { DshEnvironment } from '@deepseek-ai/dsh-bash'
import { killGroup, OutputCollector, runBash } from '../src/run.ts'
import type { RunningBash } from '../src/run.ts'
@@ -198,19 +199,19 @@ describe('stdin and extra env (set by in-process plugins)', () => {
expect(piped.stdout.text).toBe('socket\n')
})
- it('merges extra env entries onto the scrubbed environment', async () => {
- const result = await runBash(spec('echo "$DSH_EXTRA_ONE/$DSH_EXTRA_TWO"', {
- env: { DSH_EXTRA_ONE: 'alpha', DSH_EXTRA_TWO: 'beta' },
+ it('merges ordinary extra env entries onto the scrubbed environment', async () => {
+ const result = await runBash(spec('echo "$EXTRA_ONE/$EXTRA_TWO"', {
+ env: { EXTRA_ONE: 'alpha', EXTRA_TWO: 'beta' },
})).done
expect(result.stdout.text).toBe('alpha/beta\n')
})
it('an explicit extra env entry overrides the model-friendly override and the scrub', async () => {
// TERM is a model-friendly OVERRIDE (dumb); an explicit extra entry wins.
- // DSH_OVERRIDE_KEY matches the credential scrub pattern, yet an explicit
+ // EXPLICIT_OVERRIDE_KEY matches the credential scrub pattern, yet an explicit
// entry is still honored — the scrub only drops AMBIENT process.env creds.
- const result = await runBash(spec('echo "$TERM/$DSH_OVERRIDE_KEY"', {
- env: { TERM: 'xterm-256color', DSH_OVERRIDE_KEY: 'explicit-wins' },
+ const result = await runBash(spec('echo "$TERM/$EXPLICIT_OVERRIDE_KEY"', {
+ env: { TERM: 'xterm-256color', EXPLICIT_OVERRIDE_KEY: 'explicit-wins' },
})).done
expect(result.stdout.text).toBe('xterm-256color/explicit-wins\n')
})
@@ -363,13 +364,13 @@ describe('abort edge cases', () => {
})
describe('environment and spill-file hardening', () => {
- it('scrubs credential-shaped env vars from child processes', async () => {
+ it('scrubs credential-shaped and ambient DSH env vars from child processes', async () => {
process.env.DSH_TEST_API_KEY = 'super-secret'
process.env.DSH_TEST_TOKEN = 'also-secret'
process.env.DSH_TEST_PLAIN = 'visible'
try {
const result = await runBash(spec('echo "[${DSH_TEST_API_KEY:-absent}|${DSH_TEST_TOKEN:-absent}|${DSH_TEST_PLAIN:-absent}]"')).done
- expect(result.stdout.text.trim()).toBe('[absent|absent|visible]')
+ expect(result.stdout.text.trim()).toBe('[absent|absent|absent]')
} finally {
delete process.env.DSH_TEST_API_KEY
delete process.env.DSH_TEST_TOKEN
@@ -377,6 +378,29 @@ describe('environment and spill-file hardening', () => {
}
})
+ it('injects only the current trusted DSH environment after scrubbing ambient values', async () => {
+ process.env.DSH_STALE = 'old-value'
+ try {
+ const result = await runBash(spec('echo "[${DSH_STALE:-absent}|$DSH_SHELL|$DSH_SESSION_ID]"', {
+ dshEnv: { DSH_SHELL: '1', DSH_SESSION_ID: 'current-session' },
+ })).done
+ expect(result.stdout.text.trim()).toBe('[absent|1|current-session]')
+ } finally {
+ delete process.env.DSH_STALE
+ }
+ })
+
+ it('rejects DSH variables on the ordinary env channel', () => {
+ expect(() => runBash(spec('true', { env: { DSH_WRONG_CHANNEL: 'bad' } })))
+ .toThrow(/DSH_WRONG_CHANNEL.*dshEnv/)
+ })
+
+ it('rejects ordinary variables on the managed env channel', () => {
+ const invalid = { PATH: '/wrong-channel' } as unknown as DshEnvironment
+ expect(() => runBash(spec('true', { dshEnv: invalid })))
+ .toThrow(/managed bash env.*PATH.*use env/)
+ })
+
it('creates spill files with owner-only permissions and random names', async () => {
const result = await runBash(
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
diff --git a/packages/bash/bash/README.md b/packages/bash/bash/README.md
index ff2dfe0129..e4b5bf1952 100644
--- a/packages/bash/bash/README.md
+++ b/packages/bash/bash/README.md
@@ -27,11 +27,11 @@ Implementations subclass `BashExecutor` and implement the abstract methods. Disp
## Vocabulary
-`BashExecRequest` (command, workdir?, timeoutMs?, stdoutMaxBytes?, signal?, stdin?, env?, sandboxMode?) resolves to `BashExecSpec` (command, workdir, timeoutMs, stdoutMaxBytes, signal?, stdin?, env?, sandboxMode) before execution. `stdoutMaxBytes` is a trusted foreground-run capture budget for consumers that must parse complete bounded stdout; the model-facing bash tool does not expose it. `sandboxMode` is optional on the request and required-but-nullable on the resolved spec: it carries an approved one-shot escalation or the session's standing override; a sandboxing executor stamps its configured default when absent, while a non-sandboxing executor carries the field and confines nothing.
+`BashExecRequest` (command, workdir?, timeoutMs?, stdoutMaxBytes?, signal?, stdin?, env?, dshEnv?, sandboxMode?) resolves to `BashExecSpec` (command, workdir, timeoutMs, stdoutMaxBytes, signal?, stdin?, env?, dshEnv?, sandboxMode) before execution. `stdoutMaxBytes` is a trusted foreground-run capture budget for consumers that must parse complete bounded stdout; the model-facing bash tool does not expose it. `sandboxMode` is optional on the request and required-but-nullable on the resolved spec: it carries an approved one-shot escalation or the session's standing override; a sandboxing executor stamps its configured default when absent, while a non-sandboxing executor carries the field and confines nothing.
The seam also owns the per-session mode override vocabulary: the log-only `'bash/sandbox-mode'` session event, the pure `effectiveSandboxMode(events)` fold, and the `setSandboxMode(session, mode)` write path. `run()` returns `BashRunResult`; `start()` returns `BashProcess`, whose incremental read and kill methods are adapted by `dsh-tool-bash` into a generic task registration. A sandboxing executor stamps `BashSandboxInfo` on foreground results and settled process handles. See `src/types.ts` and [core-data-structures/bash.md](../../../docs/core-data-structures/bash.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 value means "none". See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
+`stdin` and ordinary `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload and `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` values. `dshEnv` is a separate trusted overlay restricted by type to managed keys; the exported `DSH_ENV_PREFIX` is the single source for that namespace, its `DshEnvironmentKey` template type, executor scrubbing, registry validation, derived built-in names, and model guidance. Model bash uses the current snapshot collected by `ctx.bashEnv`. Implementations remove inherited managed keys, reject those names in ordinary `env`, then merge `dshEnv`, so an omitted current fact cannot fall back to stale ambient state. The model-facing tool exposes none of these as parameters. All three remain optional on the resolved spec; absent means no input/overlay. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [the session environment RFC](../../../docs/rfc/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
## Model Experience
diff --git a/packages/bash/bash/src/index.ts b/packages/bash/bash/src/index.ts
index 7563224000..75a5f7f230 100644
--- a/packages/bash/bash/src/index.ts
+++ b/packages/bash/bash/src/index.ts
@@ -9,6 +9,7 @@ import { Context, Service } from 'cordis'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from './types.ts'
+export { DSH_ENV_PREFIX } from './types.ts'
export { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from './session-mode.ts'
export type {
BashExecRequest,
@@ -19,6 +20,8 @@ export type {
BashRunResult,
BashSandboxInfo,
CollectedOutput,
+ DshEnvironment,
+ DshEnvironmentKey,
} from './types.ts'
declare module 'cordis' {
diff --git a/packages/bash/bash/src/types.ts b/packages/bash/bash/src/types.ts
index 81b051ceb3..6e0ddca91e 100644
--- a/packages/bash/bash/src/types.ts
+++ b/packages/bash/bash/src/types.ts
@@ -6,6 +6,15 @@
import type { SandboxEnforcement, SandboxMode } from '@deepseek-ai/dsh-sandbox'
+/** Namespace prefix reserved for DeepSeek Harness-managed child environment facts. */
+export const DSH_ENV_PREFIX = 'DSH_' as const
+
+/** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */
+export type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}`
+
+/** Trusted DeepSeek Harness variables for one bash execution. */
+export type DshEnvironment = Readonly>
+
/**
* Sandbox facts for one run, present iff a sandboxing executor handled it.
* Facts are reported independently of process exit status so callers can
@@ -52,15 +61,20 @@ export interface BashExecRequest {
*/
stdin?: string | undefined
/**
- * Extra environment entries for the command, merged AFTER the
- * implementation's credential scrub (so an explicit entry here is honored even
- * when its name matches the scrub pattern — the caller named a value it holds,
- * not the harness's ambient secret). Set by in-process plugins (the hooks
- * bridges set `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the model-facing
- * bash tool does not expose it as a parameter (a model that needs an env var
- * uses shell syntax like `FOO=bar cmd`).
+ * Ordinary environment entries for the command, merged after the credential
+ * scrub. `DSH_*` is reserved for {@link dshEnv} and implementations reject it
+ * here. Set by in-process plugins (the hooks bridges set
+ * `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the model-facing bash tool
+ * does not expose it as a parameter.
*/
env?: Record | undefined
+ /**
+ * Harness-owned `DSH_*` variables for this execution. Executors discard
+ * ambient `DSH_*` entries before merging this snapshot, so an unavailable
+ * current fact cannot inherit a stale value from the harness process, and
+ * reject non-`DSH_*` names supplied through this managed channel.
+ */
+ dshEnv?: DshEnvironment | undefined
/** Explicit per-call sandbox mode override. */
sandboxMode?: SandboxMode | undefined
}
@@ -84,10 +98,14 @@ export interface BashExecSpec {
/** Bytes to write to stdin before closing it; absent means no stdin. */
stdin?: string | undefined
/**
- * Extra environment entries, merged after credential scrubbing so explicit
- * values win; absent means no extra entries.
+ * Ordinary environment entries carried through from
+ * {@link BashExecRequest.env}. `DSH_*` remains reserved for {@link dshEnv}.
+ * OPTIONAL on the spec for the same reason as `stdin`: absent means no
+ * ordinary extra environment.
*/
env?: Record | undefined
+ /** Managed `DSH_*` snapshot; implementations reject ordinary names. */
+ dshEnv?: DshEnvironment | undefined
/** Resolved sandbox mode; ignored by executors that do not confine. */
sandboxMode: SandboxMode | undefined
}
diff --git a/packages/bash/tool-bash/README.md b/packages/bash/tool-bash/README.md
index c20a30c98e..d5e65f1a39 100644
--- a/packages/bash/tool-bash/README.md
+++ b/packages/bash/tool-bash/README.md
@@ -24,6 +24,29 @@ The plugin also contributes the `tool:bash` prompt section (order 105): check th
`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()`.
+### Managed shell environment
+
+Every foreground and background model bash call receives a newly collected trusted `DSH_*` environment. `DSH_HOME` is the absolute Harness home resolved by [`@deepseek-ai/dsh-home`](../../util/home/README.md) (`dshHome` config, then ambient `$DSH_HOME`, then `~/.dsh`) and `DSH_SHELL=1` identifies the managed child. Agent calls additionally receive `DSH_SESSION_ID=agent.session.header.id`; when the active persistence seam locates a JSONL artifact they also receive `DSH_SESSION_JSONL=`. The JSONL path is a location hint: it may not exist before the first flush or contain the current buffered turn, and it is not an authorization credential.
+
+`ctx.bashEnv` owns collection. Other plugins can register an effect-scoped contributor with a stable name, declared keys/descriptions, and `resolve(execution: ToolExecution)`; duplicate ownership and undeclared runtime keys fail loudly, while `list()` enumerates declarations without executing providers. Harness built-ins reserve `DSH_HOME`, `DSH_SHELL`, and `DSH_SESSION_ID`; tool-bash's persistence translator owns `DSH_SESSION_JSONL` by reading the backend-neutral `sessionPersistence.locate()` seam.
+
+```ts
+import type { Context } from 'cordis'
+import type {} from '@deepseek-ai/dsh-tool-bash'
+
+export const inject = ['bashEnv']
+
+export function apply(ctx: Context): void {
+ ctx.bashEnv.register({
+ name: 'deployment-region',
+ variables: { DSH_DEPLOYMENT_REGION: { description: 'Current deployment region.' } },
+ resolve: execution => execution.agent === undefined ? {} : { DSH_DEPLOYMENT_REGION: 'cn-north' },
+ })
+}
+```
+
+The overlay is computed from the current `ToolExecution` and passed through the dedicated `BashExecRequest.dshEnv` channel. The local executor removes all inherited `DSH_*` before merging that snapshot, so nested harnesses and concurrent parent/child agents cannot leak stale identities. `process.env` is never modified. The tool description teaches the generic `$DSH_*` convention rather than naming persistence-specific variables or adding a permanent system-prompt section.
+
Result text contains stdout, an optional `[stderr]` section, then applicable sandbox-denial, timeout, signal, exit-code, and truncation markers. Timeout is reported independently of final exit status; nonzero exit remains a model-interpreted result rather than `isError`. Truncation links a safe complete spill file or reports it unavailable. Only infrastructure failures such as spawn errors and aborts produce `isError`.
When `run_in_background` is true, this plugin preflights `ctx.tasks.start()` before spawning, registers the calling agent as owner, and adapts the returned `BashProcess` handle into generic cancel/done/incremental-output hooks. The task runtime owns ids, cross-session isolation, completion notices, waiting, and disposal cleanup; this plugin only maps bash exit/sandbox facts into task output and outcome detail. `enableRunInBackground: false` removes the parameter and rejects a forced background call at execution time.
@@ -34,7 +57,7 @@ The tool owns its `presentCall`/`presentResult` render intent. A foreground call
## The tool builds its request from named args only
-The `BashExecRequest` seam carries optional `stdoutMaxBytes`, `stdin`, and `env`, used by trusted in-process plugins. This tool does **not** expose or forward them: it builds requests from named command/workdir/timeout/signal/sandbox fields only. This is not a trust boundary; the local executor's ambient credential scrub is the security control.
+The `BashExecRequest` seam carries optional `stdoutMaxBytes`, `stdin`, ordinary `env`, and managed `dshEnv`, used by trusted in-process plugins and this tool's environment registry. The model-facing tool exposes none of `stdoutMaxBytes`, `stdin`, or `env`: it builds requests from named command/workdir/timeout/signal/sandbox fields plus the registry-collected `dshEnv`. Extra model keys are ignored and cannot replace managed values. Shell syntax provides equivalent command-level behavior, while the local executor scrubs ambient credentials and stale `DSH_*` values. See the [stdin/env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
## Permissions and escalation
diff --git a/packages/bash/tool-bash/package.json b/packages/bash/tool-bash/package.json
index c8d082f46a..816be1ef88 100644
--- a/packages/bash/tool-bash/package.json
+++ b/packages/bash/tool-bash/package.json
@@ -25,7 +25,9 @@
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-user-approval": "^0.0.1",
"@deepseek-ai/dsh-bash": "^0.0.1",
+ "@deepseek-ai/dsh-home": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
+ "@deepseek-ai/dsh-session-persistence": "^0.0.1",
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-tasks": "^0.0.1",
@@ -42,9 +44,12 @@
"@deepseek-ai/dsh-user-approval": "workspace:^",
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
+ "@deepseek-ai/dsh-home": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
+ "@deepseek-ai/dsh-session-persistence": "workspace:^",
+ "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts
index 476cd1a176..cad92c9276 100644
--- a/packages/bash/tool-bash/src/index.ts
+++ b/packages/bash/tool-bash/src/index.ts
@@ -8,34 +8,203 @@
* @module @deepseek-ai/dsh-tool-bash
*/
-import type { Context } from 'cordis'
+import { Service, 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, ToolExecution, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools'
import type { Agent } from '@deepseek-ai/dsh-agent'
+import type {} from '@deepseek-ai/dsh-session-persistence'
import { assertNever } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tasks'
import type {} from '@deepseek-ai/dsh-user-approval'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
-import { effectiveSandboxMode } from '@deepseek-ai/dsh-bash'
+import { DSH_ENV_PREFIX, effectiveSandboxMode } from '@deepseek-ai/dsh-bash'
+import type { DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-bash'
+import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-home'
import { processOutcome } from './background.ts'
import { parseExitStatus, renderProcessRead, renderResult } from './render.ts'
+declare module 'cordis' {
+ interface Context {
+ bashEnv: BashEnvRegistry
+ }
+}
+
export const name = 'tool-bash'
export const inject = ['tools', 'bash', 'systemPrompt']
-/** Configures whether the model may background commands. */
+/** Configuration for the bash tool and its managed child environment. */
export interface Config {
/** Expose `run_in_background` (default true); disabled calls are also rejected. */
enableRunInBackground?: boolean
+ /** DeepSeek Harness home directory exposed as `DSH_HOME`; defaults to `$DSH_HOME` or `~/.dsh`. */
+ dshHome?: string
}
+/** Runtime configuration schema for the bash tool plugin. */
export const Config: z = z.object({
enableRunInBackground: z.boolean().default(true),
+ dshHome: z.string(),
})
+/** Model-visible metadata for one managed `DSH_*` environment variable. */
+export interface BashEnvVariable {
+ /** Concise description of the environment fact represented by the variable. */
+ description: string
+}
+
+/**
+ * A plugin contribution to the managed environment of each model bash call.
+ * Declared keys make ownership conflicts detectable before the first command;
+ * `resolve` computes only the values available for the current execution.
+ */
+export interface BashEnvContributor {
+ /** Stable contributor name used in diagnostics and duplicate detection. */
+ name: string
+ /** Complete set of `DSH_*` keys this contributor may return. */
+ variables: Readonly>
+ /**
+ * Resolve this contributor's available values for one tool execution.
+ * @param execution - the bash tool execution and its optional calling agent.
+ * @returns a partial map containing only keys declared in {@link variables}.
+ */
+ resolve(execution: ToolExecution): Readonly>>
+}
+
+/** An enumerable declaration returned by {@link BashEnvRegistry.list}. */
+export interface BashEnvVariableInfo extends BashEnvVariable {
+ /** Contributor that owns the variable. */
+ contributor: string
+ /** Declared `DSH_*` environment variable name. */
+ key: DshEnvironmentKey
+}
+
+const DSH_SHELL_KEY = `${DSH_ENV_PREFIX}SHELL` as const
+const DSH_SESSION_ID_KEY = `${DSH_ENV_PREFIX}SESSION_ID` as const
+const DSH_SESSION_JSONL_KEY = `${DSH_ENV_PREFIX}SESSION_JSONL` as const
+const RESERVED_BASH_ENV_KEYS = new Set([
+ DSH_HOME_ENV,
+ DSH_SHELL_KEY,
+ DSH_SESSION_ID_KEY,
+])
+const BASH_ENV_KEY_SUFFIX = /^[A-Z][A-Z0-9_]*$/
+
+/**
+ * Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables.
+ * The namespace is rebuilt for every model bash call: ambient `DSH_*` values
+ * are discarded by the executor, then the registry's current snapshot is
+ * injected. Built-in shell facts remain owned by the registry itself while
+ * plugins can register additional, enumerable facts with effect-scoped
+ * disposal.
+ */
+export class BashEnvRegistry extends Service {
+ private readonly contributors = new Map()
+ private readonly keyOwners = new Map()
+ private readonly dshHome: string
+
+ /**
+ * Create and install the `ctx.bashEnv` service.
+ * @param ctx - Cordis context that owns the service and registrations.
+ * @param config - home-directory configuration for the built-in variables.
+ */
+ constructor(ctx: Context, config: Config = {}) {
+ super(ctx, 'bashEnv')
+ this.dshHome = resolveDshHome(config.dshHome)
+ }
+
+ /**
+ * Register one environment contributor. Names and keys are unique; built-in
+ * keys are reserved. Registration is disposed with the calling plugin fiber.
+ * @param contributor - declared key ownership and per-execution resolver.
+ * @returns the disposer that unregisters the contribution.
+ */
+ register(contributor: BashEnvContributor): () => void {
+ const dispose = this.ctx.effect(function* (this: BashEnvRegistry) {
+ if (contributor.name.trim().length === 0) {
+ throw new Error('bash env contributor name must be non-empty')
+ }
+ if (this.contributors.has(contributor.name)) {
+ throw new Error(`bash env contributor "${contributor.name}" is already registered`)
+ }
+
+ const variables = Object.entries(contributor.variables) as [DshEnvironmentKey, BashEnvVariable][]
+ for (const [key, variable] of variables) {
+ if (!key.startsWith(DSH_ENV_PREFIX)
+ || !BASH_ENV_KEY_SUFFIX.test(key.slice(DSH_ENV_PREFIX.length))) {
+ throw new Error(`bash env contributor "${contributor.name}" declared invalid key "${key}"`)
+ }
+ if (RESERVED_BASH_ENV_KEYS.has(key)) {
+ throw new Error(`bash env contributor "${contributor.name}" cannot own reserved key "${key}"`)
+ }
+ if (variable.description.trim().length === 0) {
+ throw new Error(`bash env contributor "${contributor.name}" must describe "${key}"`)
+ }
+ const owner = this.keyOwners.get(key)
+ if (owner !== undefined) {
+ throw new Error(`bash env key "${key}" is already owned by contributor "${owner}"; contributor "${contributor.name}" cannot also own it`)
+ }
+ }
+
+ this.contributors.set(contributor.name, contributor)
+ for (const [key] of variables) this.keyOwners.set(key, contributor.name)
+ yield () => {
+ this.contributors.delete(contributor.name)
+ for (const [key] of variables) this.keyOwners.delete(key)
+ }
+ }.bind(this), 'bashEnv.register()')
+ return () => void dispose()
+ }
+
+ /**
+ * Build the trusted `DSH_*` snapshot for one bash tool execution.
+ * @param execution - the current tool execution.
+ * @returns an immutable environment overlay containing built-ins and current contributions.
+ */
+ collect(execution: ToolExecution): DshEnvironment {
+ const values: Record = {
+ [DSH_HOME_ENV]: this.dshHome,
+ [DSH_SHELL_KEY]: '1',
+ }
+ if (execution.agent !== undefined) {
+ values[DSH_SESSION_ID_KEY] = execution.agent.session.header.id
+ }
+
+ for (const contributor of [...this.contributors.values()].sort((left, right) => left.name.localeCompare(right.name))) {
+ const resolved = contributor.resolve(execution)
+ for (const [rawKey, value] of Object.entries(resolved)) {
+ const key = rawKey as DshEnvironmentKey
+ if (!Object.hasOwn(contributor.variables, key)) {
+ throw new Error(`bash env contributor "${contributor.name}" returned undeclared key "${key}"`)
+ }
+ if (typeof value !== 'string') {
+ throw new Error(`bash env contributor "${contributor.name}" returned a non-string value for "${key}"`)
+ }
+ values[key] = value
+ }
+ }
+
+ return Object.freeze(Object.fromEntries(Object.entries(values).sort(([left], [right]) => left.localeCompare(right))))
+ }
+
+ // TODO(bash-env-list-builtins): Include registry-owned built-ins before diagnostics,
+ // prompt, or UI code treats list() as an exhaustive environment catalog.
+ /**
+ * Enumerate plugin-contributed variables without executing their resolvers.
+ * @returns declarations sorted by environment variable name.
+ */
+ list(): BashEnvVariableInfo[] {
+ return [...this.contributors.values()]
+ .flatMap(contributor => Object.entries(contributor.variables).map(([key, variable]) => ({
+ contributor: contributor.name,
+ description: variable.description,
+ key: key as DshEnvironmentKey,
+ })))
+ .sort((left, right) => left.key.localeCompare(right.key))
+ }
+}
+
/** Parsed tool args; execute validates value constraints absent from SchemaSpec. */
interface BashToolArgs {
command: string
@@ -82,6 +251,7 @@ function bashDescription(backgroundEnabled: boolean, escalationModes: readonly S
const base = '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]`. '
+ + `Current harness environment facts are exposed through managed \`$${DSH_ENV_PREFIX}*\` variables; inspect them when needed. `
+ 'Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. '
+ 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. '
+ background
@@ -153,7 +323,22 @@ function resolveWorkdir(modelWorkdir: string | undefined, exec: { agent?: Agent
return modelWorkdir
}
-export function apply(ctx: Context, config: Config): void {
+export function apply(ctx: Context, config: Config = {}): void {
+ const bashEnv = new BashEnvRegistry(ctx, config)
+ bashEnv.register({
+ name: 'session-persistence',
+ variables: {
+ [DSH_SESSION_JSONL_KEY]: {
+ description: 'Absolute target path of the current session JSONL when the active persistence backend provides one.',
+ },
+ },
+ resolve(execution) {
+ const agent = execution.agent
+ if (agent === undefined) return {}
+ const location = ctx.get('sessionPersistence')?.locate(agent.session.header)
+ return location?.kind === 'jsonl' ? { [DSH_SESSION_JSONL_KEY]: location.path } : {}
+ },
+ })
const backgroundEnabled = config.enableRunInBackground ?? true
const defaultMode = ctx.bash.sandboxMode
const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS
@@ -235,10 +420,12 @@ export function apply(ctx: Context, config: Config): void {
? await approveEscalation(args.sandbox_permissions, args.justification, exec)
: sessionOverride(exec)
const workdir = resolveWorkdir(args.workdir, exec)
+ const dshEnv = bashEnv.collect(exec)
const request = {
command: args.command,
...workdir !== undefined ? { workdir } : {},
...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {},
+ dshEnv,
...sandboxMode !== undefined ? { sandboxMode } : {},
}
if (args.run_in_background === true) {
diff --git a/packages/bash/tool-bash/tests/bash-env.spec.ts b/packages/bash/tool-bash/tests/bash-env.spec.ts
new file mode 100644
index 0000000000..03d29b572b
--- /dev/null
+++ b/packages/bash/tool-bash/tests/bash-env.spec.ts
@@ -0,0 +1,190 @@
+import { homedir } from 'node:os'
+import { join, resolve } from 'node:path'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { Context } from 'cordis'
+import { CallId } from '@deepseek-ai/dsh-llm'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import type { ToolExecution } from '@deepseek-ai/dsh-tools'
+import { BashEnvRegistry } from '@deepseek-ai/dsh-tool-bash'
+
+afterEach(() => vi.unstubAllEnvs())
+
+function execution(sessionId?: string): ToolExecution {
+ return {
+ token: Symbol('bash-env-test') as ToolExecution['token'],
+ callId: CallId('bash-env-call'),
+ name: 'bash',
+ arguments: { command: 'true' },
+ ...(sessionId === undefined
+ ? {}
+ : { agent: { session: { header: { version: 0, id: sessionId, createdAt: 0 } } } as Agent }),
+ }
+}
+
+describe('BashEnvRegistry', () => {
+ it('collects unconditional shell facts and the current agent session id', () => {
+ const ctx = new Context()
+ const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' })
+
+ expect(registry.collect(execution())).toEqual({
+ DSH_HOME: resolve('./test-dsh-home'),
+ DSH_SHELL: '1',
+ })
+ expect(registry.collect(execution('session-a'))).toEqual({
+ DSH_HOME: resolve('./test-dsh-home'),
+ DSH_SESSION_ID: 'session-a',
+ DSH_SHELL: '1',
+ })
+ })
+
+ it('resolves DSH_HOME from the ambient override or the user-home default', () => {
+ vi.stubEnv('DSH_HOME', './ambient-dsh-home')
+ const fromEnvironment = new BashEnvRegistry(new Context())
+ expect(fromEnvironment.collect(execution()).DSH_HOME).toBe(resolve('./ambient-dsh-home'))
+
+ vi.stubEnv('DSH_HOME', undefined)
+ const fromDefault = new BashEnvRegistry(new Context())
+ expect(fromDefault.collect(execution()).DSH_HOME).toBe(join(homedir(), '.dsh'))
+ })
+
+ it('collects declared contributor variables and omits unavailable values', () => {
+ const ctx = new Context()
+ const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' })
+ registry.register({
+ name: 'optional-session-fact',
+ variables: {
+ DSH_SESSION_OPTIONAL: { description: 'Optional session-scoped test fact.' },
+ },
+ resolve: exec => exec.agent === undefined ? {} : { DSH_SESSION_OPTIONAL: exec.agent.session.header.id },
+ })
+ registry.register({
+ name: 'always-available-fact',
+ variables: {
+ DSH_ALWAYS_AVAILABLE: { description: 'Always-available test fact.' },
+ },
+ resolve: () => ({ DSH_ALWAYS_AVAILABLE: 'yes' }),
+ })
+
+ expect(registry.collect(execution())).not.toHaveProperty('DSH_SESSION_OPTIONAL')
+ expect(registry.collect(execution()).DSH_ALWAYS_AVAILABLE).toBe('yes')
+ expect(registry.collect(execution('session-b')).DSH_SESSION_OPTIONAL).toBe('session-b')
+ expect(registry.list()).toEqual([
+ {
+ contributor: 'always-available-fact',
+ description: 'Always-available test fact.',
+ key: 'DSH_ALWAYS_AVAILABLE',
+ },
+ {
+ contributor: 'optional-session-fact',
+ description: 'Optional session-scoped test fact.',
+ key: 'DSH_SESSION_OPTIONAL',
+ },
+ ])
+ })
+
+ it('rejects duplicate variable ownership at registration time', () => {
+ const ctx = new Context()
+ const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' })
+ registry.register({
+ name: 'first',
+ variables: { DSH_SHARED: { description: 'First owner.' } },
+ resolve: () => ({ DSH_SHARED: 'first' }),
+ })
+
+ expect(() => registry.register({
+ name: 'second',
+ variables: { DSH_SHARED: { description: 'Second owner.' } },
+ resolve: () => ({ DSH_SHARED: 'second' }),
+ })).toThrow(/DSH_SHARED.*first.*second|DSH_SHARED.*second.*first/)
+ })
+
+ it('rejects duplicate contributor names and malformed declarations', () => {
+ const registry = new BashEnvRegistry(new Context(), { dshHome: './test-dsh-home' })
+ registry.register({
+ name: 'declared',
+ variables: { DSH_DECLARED: { description: 'Declared fact.' } },
+ resolve: () => ({}),
+ })
+
+ expect(() => registry.register({
+ name: 'declared',
+ variables: { DSH_ANOTHER: { description: 'Another fact.' } },
+ resolve: () => ({}),
+ })).toThrow(/already registered/)
+ expect(() => registry.register({
+ name: ' ',
+ variables: { DSH_BLANK_NAME: { description: 'Blank owner.' } },
+ resolve: () => ({}),
+ })).toThrow(/name must be non-empty/)
+ expect(() => registry.register({
+ name: 'invalid-key',
+ variables: { dsh_invalid: { description: 'Invalid key.' } } as unknown as Record<'DSH_INVALID', { description: string }>,
+ resolve: () => ({}),
+ })).toThrow(/invalid key/)
+ expect(() => registry.register({
+ name: 'reserved-key',
+ variables: { DSH_HOME: { description: 'Reserved key.' } },
+ resolve: () => ({}),
+ })).toThrow(/reserved key/)
+ expect(() => registry.register({
+ name: 'blank-description',
+ variables: { DSH_BLANK_DESCRIPTION: { description: ' ' } },
+ resolve: () => ({}),
+ })).toThrow(/must describe/)
+ })
+
+ it('rejects undeclared variables returned by a contributor', () => {
+ const ctx = new Context()
+ const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' })
+ registry.register({
+ name: 'drifted-provider',
+ variables: { DSH_DECLARED: { description: 'Declared fact.' } },
+ resolve: () => ({ DSH_UNDECLARED: 'bad' }),
+ })
+
+ expect(() => registry.collect(execution())).toThrow(/drifted-provider.*DSH_UNDECLARED/)
+ })
+
+ it('rejects non-string values returned by a contributor', () => {
+ const registry = new BashEnvRegistry(new Context(), { dshHome: './test-dsh-home' })
+ registry.register({
+ name: 'wrong-value-type',
+ variables: { DSH_STRING: { description: 'String fact.' } },
+ resolve: () => ({ DSH_STRING: 42 }) as unknown as Record<'DSH_STRING', string>,
+ })
+
+ expect(() => registry.collect(execution())).toThrow(/wrong-value-type.*non-string.*DSH_STRING/)
+ })
+
+ it('removes an effect-scoped contributor when its plugin is disposed', async () => {
+ const ctx = new Context()
+ const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' })
+ const fiber = await ctx.plugin({
+ inject: ['bashEnv'],
+ apply(inner: Context) {
+ inner.bashEnv.register({
+ name: 'temporary',
+ variables: { DSH_TEMPORARY: { description: 'Temporary fact.' } },
+ resolve: () => ({ DSH_TEMPORARY: 'present' }),
+ })
+ },
+ })
+
+ expect(registry.collect(execution()).DSH_TEMPORARY).toBe('present')
+ await fiber.dispose()
+ expect(registry.collect(execution())).not.toHaveProperty('DSH_TEMPORARY')
+ })
+
+ it('returns an explicit contributor disposer', () => {
+ const registry = new BashEnvRegistry(new Context(), { dshHome: './test-dsh-home' })
+ const dispose = registry.register({
+ name: 'explicit-disposal',
+ variables: { DSH_EXPLICIT_DISPOSAL: { description: 'Explicitly disposed fact.' } },
+ resolve: () => ({ DSH_EXPLICIT_DISPOSAL: 'present' }),
+ })
+
+ expect(registry.collect(execution()).DSH_EXPLICIT_DISPOSAL).toBe('present')
+ dispose()
+ expect(registry.collect(execution())).not.toHaveProperty('DSH_EXPLICIT_DISPOSAL')
+ })
+})
diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts
index 0a2a1ba883..dbeac75e28 100644
--- a/packages/bash/tool-bash/tests/integration.spec.ts
+++ b/packages/bash/tool-bash/tests/integration.spec.ts
@@ -1,6 +1,10 @@
-import { describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
-import type { SessionEvent } from '@deepseek-ai/dsh-session'
+import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
+import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import { AgentId } from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
@@ -16,18 +20,25 @@ import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent
* (tool/call + tool/result session events, the generic `ctx.tasks` runtime,
* agent.inject completion notices).
*/
-async function harness(adapter: MockAdapter) {
+async function harness(adapter: MockAdapter, sessionRoot?: string, dshHome?: string) {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
+ if (sessionRoot !== undefined) await ctx.plugin(SessionPersistenceJsonl, { root: sessionRoot })
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(TaskService)
await ctx.plugin(ToolTasks)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
- await ctx.plugin(ToolBash)
+ await ctx.plugin(ToolBash, dshHome === undefined ? {} : { dshHome })
ctx.llm.registerAdapter(['mock'], adapter)
return ctx
}
+const dirs: string[] = []
+afterEach(() => {
+ vi.unstubAllEnvs()
+ for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true })
+})
+
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
@@ -75,6 +86,39 @@ async function pollUntil(predicate: () => boolean, timeoutMs = 5_000): Promise {
+ it('first-turn bash receives session identity before the lazy JSONL file materializes', async () => {
+ const root = mkdtempSync(join(tmpdir(), 'dsh-bash-session-env-'))
+ dirs.push(root)
+ const dshHome = join(root, 'dsh-home')
+ vi.stubEnv('DSH_STALE_PARENT', 'stale')
+ const adapter = new MockAdapter([
+ toolCallResponse('call-1', 'bash', {
+ command: 'printf \'%s\\n%s\\n%s\\n%s\\n%s\\n\' "$DSH_HOME" "$DSH_SHELL" "$DSH_SESSION_ID" "$DSH_SESSION_JSONL" "${DSH_STALE_PARENT-unset}"; if [ -e "$DSH_SESSION_JSONL" ]; then printf \'present\\n\'; else printf \'absent\\n\'; fi',
+ description: 'inspect session environment',
+ }),
+ textResponse('Session environment inspected.'),
+ ])
+ const ctx = await harness(adapter, root, dshHome)
+ const handle = await ctx.agents.create({
+ agentId: AgentId('session-env'),
+ sessionId: SessionId('session-env-id'),
+ agentOptions: { model: 'mock' },
+ })
+ const agent = handle.agent as ReactLoopAgent
+ const location = ctx.sessionPersistence.locate(agent.session.header)
+ expect(location?.kind).toBe('jsonl')
+
+ agent.send([{ type: 'text', text: 'inspect the current session' }])
+ await waitForIdle(ctx, agent)
+
+ const result = findEvent(events(agent), 'tool/result')
+ expect(resultText(result)).toBe(`${dshHome}\n1\nsession-env-id\n${location?.path}\nunset\nabsent\n`)
+ expect(existsSync(location!.path)).toBe(true)
+ const header = JSON.parse(readFileSync(location!.path, 'utf8').split('\n')[0]!) as { type: string; id: string }
+ expect(header).toMatchObject({ type: 'session', id: 'session-env-id' })
+ await handle.dispose()
+ })
+
it('foreground: model calls bash, sees the result, replies', async () => {
const adapter = new MockAdapter([
toolCallResponse('call-1', 'bash', { command: 'echo integration-ok', description: 'test command' }, 'Running it.'),
diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts
index 5ef710779d..c19846ab13 100644
--- a/packages/bash/tool-bash/tests/tools.spec.ts
+++ b/packages/bash/tool-bash/tests/tools.spec.ts
@@ -10,6 +10,8 @@ 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 SessionStore from '@deepseek-ai/dsh-session'
+import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import TaskService from '@deepseek-ai/dsh-tasks'
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import ApprovalService from '@deepseek-ai/dsh-user-approval'
@@ -931,6 +933,8 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => {
})
describe('the model-facing bash tool builds its request from named args only (no {...args} forward)', () => {
+ const recordingDshHome = join(spillDir, 'dsh-home')
+
/**
* Records every {@link BashExecRequest} the consumer hands to `resolve()`, so a
* test can assert what the model-facing tool DID and DID NOT forward. The `bash`
@@ -956,6 +960,7 @@ 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 } : {},
+ ...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {},
sandboxMode: request.sandboxMode,
}
}
@@ -977,18 +982,126 @@ describe('the model-facing bash tool builds its request from named args only (no
}
}
- async function setupRecording() {
+ async function setupRecording(withJsonl = false) {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
+ if (withJsonl) {
+ await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionPersistenceJsonl, { root: join(spillDir, 'jsonl') })
+ }
await ctx.plugin(TaskService)
await ctx.plugin(ToolTasks)
await ctx.plugin(RecordingBashExecutor)
- await ctx.plugin(ToolBash)
+ await ctx.plugin(ToolBash, { dshHome: recordingDshHome })
return { ctx, bash: ctx.bash as RecordingBashExecutor }
}
+ it('describes the managed harness environment namespace to the model', async () => {
+ const { ctx } = await setupRecording()
+ const description = ctx.tools.get('bash')?.description ?? ''
+ expect(description).toContain('$DSH_*')
+ expect(description).not.toContain('DSH_SESSION_JSONL')
+ })
+
+ it('injects the session id and JSONL target path into a foreground request', async () => {
+ const { ctx, bash } = await setupRecording(true)
+ const agent = registerFakeAgent(ctx, 'request-fg', () => undefined)
+ const path = ctx.sessionPersistence.locate(agent.session.header)?.path
+
+ await ctx.tools.execute({
+ callId: CallId('session-env-fg'),
+ name: 'bash',
+ arguments: { command: 'true', description: 'run command' },
+ agent,
+ })
+
+ expect(bash.requests[0]?.dshEnv).toEqual({
+ DSH_HOME: recordingDshHome,
+ DSH_SESSION_ID: 'request-fg',
+ DSH_SESSION_JSONL: path,
+ DSH_SHELL: '1',
+ })
+ })
+
+ it('injects the same trusted variables into a background request without forwarding model env', async () => {
+ const { ctx, bash } = await setupRecording(true)
+ const agent = registerFakeAgent(ctx, 'request-bg', () => undefined)
+ const path = ctx.sessionPersistence.locate(agent.session.header)?.path
+
+ await ctx.tools.execute({
+ callId: CallId('session-env-bg'),
+ name: 'bash',
+ arguments: {
+ command: 'sleep 1',
+ description: 'run command',
+ run_in_background: true,
+ env: { DSH_SESSION_ID: 'spoofed', DSH_SESSION_JSONL: '/tmp/spoofed' },
+ },
+ agent,
+ })
+
+ expect(bash.requests[0]?.env).toBeUndefined()
+ expect(bash.requests[0]?.dshEnv).toEqual({
+ DSH_HOME: recordingDshHome,
+ DSH_SESSION_ID: 'request-bg',
+ DSH_SESSION_JSONL: path,
+ DSH_SHELL: '1',
+ })
+ })
+
+ it('injects built-ins and the stable session id when no JSONL locator is available', async () => {
+ const { ctx, bash } = await setupRecording()
+ const agent = registerFakeAgent(ctx, 'request-id-only', () => undefined)
+ const ambient = process.env.DSH_SESSION_ID
+
+ await ctx.tools.execute({
+ callId: CallId('session-env-id-only'),
+ name: 'bash',
+ arguments: { command: 'true', description: 'run command' },
+ agent,
+ })
+
+ expect(bash.requests[0]?.dshEnv).toEqual({
+ DSH_HOME: recordingDshHome,
+ DSH_SESSION_ID: 'request-id-only',
+ DSH_SHELL: '1',
+ })
+ expect(process.env.DSH_SESSION_ID).toBe(ambient)
+ })
+
+ it('keeps parent and child agent session environments isolated', async () => {
+ const { ctx, bash } = await setupRecording(true)
+ const parent = registerFakeAgent(ctx, 'request-parent', () => undefined)
+ const child = registerFakeAgent(ctx, 'request-child', () => undefined)
+
+ for (const [callId, agent] of [['parent', parent], ['child', child]] as const) {
+ await ctx.tools.execute({
+ callId: CallId(`session-env-${callId}`),
+ name: 'bash',
+ arguments: { command: 'true', description: 'run command' },
+ agent,
+ })
+ }
+
+ expect(bash.requests.map(request => request.dshEnv)).toEqual([
+ {
+ DSH_HOME: recordingDshHome,
+ DSH_SESSION_ID: 'request-parent',
+ DSH_SESSION_JSONL: ctx.sessionPersistence.locate(parent.session.header)?.path,
+ DSH_SHELL: '1',
+ },
+ {
+ DSH_HOME: recordingDshHome,
+ DSH_SESSION_ID: 'request-child',
+ DSH_SESSION_JSONL: ctx.sessionPersistence.locate(child.session.header)?.path,
+ DSH_SHELL: '1',
+ },
+ ])
+ expect(bash.requests[0]?.dshEnv?.DSH_SESSION_JSONL).not.toBe(bash.requests[1]?.dshEnv?.DSH_SESSION_JSONL)
+ })
+
it('does not forward trusted-only fields even when the model includes them as extra arguments', async () => {
const { ctx, bash } = await setupRecording()
// Unknown `env` and `stdin` keys are ignored by the schema and named request construction.
diff --git a/packages/bash/tool-bash/tsconfig.json b/packages/bash/tool-bash/tsconfig.json
index 6957c10fdf..407e78ebd8 100644
--- a/packages/bash/tool-bash/tsconfig.json
+++ b/packages/bash/tool-bash/tsconfig.json
@@ -26,9 +26,15 @@
{
"path": "../../core/agent"
},
+ {
+ "path": "../../session-persistence/session-persistence"
+ },
{
"path": "../../bash/bash"
},
+ {
+ "path": "../../util/home"
+ },
{
"path": "../../tasks/tasks"
},
diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts
index 9fb6383459..ba9873f56f 100644
--- a/packages/cordis/tool-cordis/src/api-catalog.ts
+++ b/packages/cordis/tool-cordis/src/api-catalog.ts
@@ -91,6 +91,15 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
'abstract start(spec: BashExecSpec): BashProcess',
],
},
+ {
+ key: 'bashEnv',
+ summary: 'Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables.',
+ methods: [
+ 'register(contributor: BashEnvContributor): () => void',
+ 'collect(execution: ToolExecution): DshEnvironment',
+ 'list(): BashEnvVariableInfo[]',
+ ],
+ },
{
key: 'codeRuntime',
summary: 'Registers one `ctx.codeRuntime` implementation.',
@@ -150,6 +159,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
key: 'sessionPersistence',
summary: 'Durable append-only session storage.',
methods: [
+ 'abstract locate(meta: SessionHeader): SessionLocation | undefined',
'abstract create(meta: SessionHeader): Promise',
'abstract append(id: SessionId, events: readonly SessionEvent[]): Promise',
'abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }>',
@@ -575,13 +585,25 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'AssembledSection',
declaration: 'export interface AssembledSection {\n name: string;\n text: string;\n}',
},
+ {
+ name: 'BashEnvContributor',
+ declaration: 'export interface BashEnvContributor {\n name: string;\n variables: Readonly>;\n resolve(execution: ToolExecution): Readonly>>;\n}',
+ },
+ {
+ name: 'BashEnvVariable',
+ declaration: 'export interface BashEnvVariable {\n description: string;\n}',
+ },
+ {
+ name: 'BashEnvVariableInfo',
+ declaration: 'export interface BashEnvVariableInfo extends BashEnvVariable {\n contributor: string;\n key: DshEnvironmentKey;\n}',
+ },
{
name: 'BashExecRequest',
- declaration: 'export interface BashExecRequest {\n command: string;\n workdir?: string | undefined;\n timeoutMs?: number | undefined;\n stdoutMaxBytes?: number | undefined;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record | undefined;\n sandboxMode?: SandboxMode | undefined;\n}',
+ declaration: 'export interface BashExecRequest {\n command: string;\n workdir?: string | undefined;\n timeoutMs?: number | undefined;\n stdoutMaxBytes?: number | undefined;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record | undefined;\n dshEnv?: DshEnvironment | undefined;\n sandboxMode?: SandboxMode | undefined;\n}',
},
{
name: 'BashExecSpec',
- declaration: 'export interface BashExecSpec {\n command: string;\n workdir: string;\n timeoutMs: number;\n stdoutMaxBytes: number;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record | undefined;\n sandboxMode: SandboxMode | undefined;\n}',
+ declaration: 'export interface BashExecSpec {\n command: string;\n workdir: string;\n timeoutMs: number;\n stdoutMaxBytes: number;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record | undefined;\n dshEnv?: DshEnvironment | undefined;\n sandboxMode: SandboxMode | undefined;\n}',
},
{
name: 'BashProcess',
@@ -679,6 +701,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'DiffResultView',
declaration: 'export interface DiffResultView {\n card: \'diff\';\n title?: string;\n diffs: FileDiff[];\n}',
},
+ {
+ name: 'DshEnvironment',
+ declaration: 'export type DshEnvironment = Readonly>;',
+ },
+ {
+ name: 'DshEnvironmentKey',
+ declaration: 'export type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}`;',
+ },
{
name: 'FileDiff',
declaration: 'export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n}',
@@ -875,6 +905,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SessionLineageTrace',
declaration: 'export type SessionLineageTrace = {\n target: SessionRecord;\n ancestors: SessionRecord[];\n descendants: SessionLineageNode[];\n} & ({\n complete: true;\n root: SessionRecord;\n} | {\n complete: false;\n unresolvedParentId: SessionId;\n});',
},
+ {
+ name: 'SessionLocation',
+ declaration: 'export interface SessionLocation {\n readonly kind: string;\n readonly path: string;\n}',
+ },
{
name: 'SessionRecord',
declaration: 'export interface SessionRecord {\n header: SessionHeader;\n live: boolean;\n persisted: boolean;\n}',
diff --git a/packages/examples/acp-demo/README.md b/packages/examples/acp-demo/README.md
index c778ab6c2e..5809e9ab49 100644
--- a/packages/examples/acp-demo/README.md
+++ b/packages/examples/acp-demo/README.md
@@ -28,6 +28,7 @@ Because the package wires no logger entry, an ACP leaf has **nothing to get wron
| `model` | (required) | the per-session agent template the bridge creates agents from |
| `persona` | — | the deployment persona template (may reference `{{model}}`/`{{cwd}}`), routed to `dsh-system-prompt` |
| `toolOrder` | — | explicit model-facing tool order (a name list with one `''` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` |
+| `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home exposed to model bash and used by local skill discovery |
| `tools` | `{ mode: 'native' }` | tool-registry presentation config (`native` / `code` / `both`), routed through `dsh-agent-spine-demo` |
| `skills` | owner defaults | registry-cache, local-provider, and model-facing skill-tool config, routed through `dsh-agent-spine-demo` |
| `toolBash` | owner defaults | model-facing bash config routed through `dsh-agent-spine-demo`, including bash's producer-local `enableRunInBackground` |
diff --git a/packages/examples/acp-demo/src/index.ts b/packages/examples/acp-demo/src/index.ts
index e52b21b9f6..c6a58af2f0 100644
--- a/packages/examples/acp-demo/src/index.ts
+++ b/packages/examples/acp-demo/src/index.ts
@@ -38,6 +38,8 @@ export interface Config {
toolOrder?: string[]
/** Tool-registry config — its presentation `mode` (forwarded through agent-spine-demo; see dsh-tools). */
tools?: ToolsConfig
+ /** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */
+ dshHome?: string
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
persistenceRoot?: string
/** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */
@@ -61,6 +63,7 @@ export const Config: z = z.object({
// schemastery's native [] default would read as an invalid configured list.
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
tools: ToolRegistry.Config,
+ dshHome: z.string(),
// TODO(single-default-literal): share this schema default and the defensive
// apply() fallback through one named constant while retaining both boundaries.
persistenceRoot: z.string().default('./.sessions'),
diff --git a/packages/examples/acp-demo/tests/acp-agent.spec.ts b/packages/examples/acp-demo/tests/acp-agent.spec.ts
index 34dd0054cc..a86d1261e8 100644
--- a/packages/examples/acp-demo/tests/acp-agent.spec.ts
+++ b/packages/examples/acp-demo/tests/acp-agent.spec.ts
@@ -118,8 +118,9 @@ describe('dsh-acp-demo composition', () => {
})
})
- it('forwards skill config into agent-spine-demo', async () => {
- const ctx = await mount({ model: 'mock', persona: 'hi', skills: await isolatedSkillsConfig(6), workspaceContext: false })
+ it('forwards skill config and dshHome into agent-spine-demo', async () => {
+ const skills = await isolatedSkillsConfig(6)
+ const ctx = await mount({ model: 'mock', persona: 'hi', dshHome: skills.local!.dshHome!, skills, workspaceContext: false })
ctx.skills.register({ name: 'acp-skill', description: 'ACP skill', source: 'runtime', content: 'body' })
expect(JSON.stringify(await composePrefix(ctx))).toContain('- `acp-skill`: ACP...')
await ctx.fiber.dispose()
diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md
index 0d02d5f597..45797a58c8 100644
--- a/packages/examples/agent-spine-demo/README.md
+++ b/packages/examples/agent-spine-demo/README.md
@@ -42,11 +42,11 @@ This is the [interface/implementation/consumer seam](../../../docs/rfc/implement
```ts
import type { Config } from '@deepseek-ai/dsh-agent-spine-demo'
-// { agents?, persona?, toolOrder?, tools?, skills?, workspaceContext, toolBash?, toolTasks? }
+// { agents?, persona?, toolOrder?, tools?, dshHome?, skills?, workspaceContext, toolBash?, toolTasks? }
// workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults.
```
-The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. `toolBash.enableRunInBackground` controls only the bash producer, while `toolTasks` controls generic `task_output` wait bounds; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields.
+The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer, while `toolTasks` controls generic `task_output` wait bounds; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields.
## Why a code bundle, not a shared YAML include
diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json
index 505e93e46f..772d6c059b 100644
--- a/packages/examples/agent-spine-demo/package.json
+++ b/packages/examples/agent-spine-demo/package.json
@@ -26,6 +26,7 @@
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-agent-loop": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
+ "@deepseek-ai/dsh-home": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-workspace-context": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
@@ -45,6 +46,7 @@
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
+ "@deepseek-ai/dsh-home": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-workspace-context": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts
index 5652078a0f..d8357c6c9a 100644
--- a/packages/examples/agent-spine-demo/src/index.ts
+++ b/packages/examples/agent-spine-demo/src/index.ts
@@ -25,6 +25,7 @@ import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
import * as toolSkill from '@deepseek-ai/dsh-tool-skill'
import * as toolTasks from '@deepseek-ai/dsh-tool-tasks'
import AgentLoop, { type Config as AgentLoopConfig } from '@deepseek-ai/dsh-agent-loop'
+import { resolveDshHome } from '@deepseek-ai/dsh-home'
export const name = 'agent-spine-demo'
@@ -44,13 +45,14 @@ export interface SkillConfig {
* bridge, simply omits it), `persona` and `toolOrder` to the system-prompt
* plugin (the deployment's persona section and the explicit model-facing tool
* order), the `tools` object to the tool registry (its presentation `mode`),
- * `skills` to the skill registry/local provider/tool consumer,
- * `workspaceContext` to the workspace-context loader, and
- * `toolBash`/`toolTasks` to the model-facing tool plugins this bundle owns.
- * Owner schemas supply defaults for optional input; workspace context instead
- * requires an explicit byte budget or `false` because it changes model-visible
- * input. Producer opt-in stays producer-local: `toolBash` configures bash only;
- * independently composed producers keep their own config.
+ * `dshHome` to bash environment and local skill discovery, `skills` to the
+ * skill registry/local provider/tool consumer, `workspaceContext` to the
+ * workspace-context loader, and `toolBash`/`toolTasks` to the model-facing tool
+ * plugins this bundle owns. Owner schemas supply defaults for optional input;
+ * workspace context instead requires an explicit byte budget or `false` because
+ * it changes model-visible input. Producer opt-in stays producer-local:
+ * `toolBash` configures bash only; independently composed producers keep their
+ * own config.
*/
export interface Config {
/** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */
@@ -61,6 +63,8 @@ export interface Config {
toolOrder?: SystemPromptConfig['toolOrder']
/** The tool registry's config — its presentation `mode` (see dsh-tools' `Config`). */
tools?: ToolsConfig
+ /** DeepSeek Harness home directory shared by shell context and local skill discovery. */
+ dshHome?: string
/** Workspace-context loader controls with an explicit byte budget; set `false` for hermetic prompts. */
workspaceContext: workspaceContext.Config | false
/** Skill registry, local provider, and model-facing consumer config. */
@@ -90,11 +94,12 @@ export const Config = z.intersect([
SystemPrompt.Config,
z.object({
tools: ToolRegistry.Config,
+ dshHome: z.string(),
skills: SkillConfigSchema,
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
toolBash: ToolBashConfigSchema,
toolTasks: ToolTasksConfigSchema,
- }) as unknown as z>,
+ }) as unknown as z>,
]) as unknown as z
/**
@@ -107,6 +112,7 @@ export function pickSpineConfig(config: Omit): Omit): Omit {
await ctx.fiber.dispose()
})
+ it('shares top-level dshHome between local skills and the managed bash environment', async () => {
+ const home = await mkdtemp(join(tmpdir(), 'dsh-agent-core-shared-home-'))
+ const agentsHome = await mkdtemp(join(tmpdir(), 'dsh-agent-core-shared-agents-'))
+ await mkdir(join(home, 'skills'), { recursive: true })
+ await writeFile(join(home, 'skills', 'shared-skill.md'), '---\nname: shared-skill\ndescription: Shared home skill\n---\n\nShared body.\n')
+
+ const ctx = await mount({
+ dshHome: home,
+ workspaceContext: false,
+ skills: { local: { agentsHome } },
+ }, true)
+
+ expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['shared-skill'])
+ const execution: ToolExecution = {
+ token: Symbol('agent-core-dsh-home-test') as ToolExecution['token'],
+ callId: CallId('agent-core-dsh-home'),
+ name: 'bash',
+ arguments: { command: 'true' },
+ }
+ expect(ctx.bashEnv.collect(execution)).toMatchObject({ DSH_HOME: home, DSH_SHELL: '1' })
+ await ctx.fiber.dispose()
+ })
+
+ it('rejects conflicting global and nested DSH home directories', () => {
+ expect(() => {
+ agentCore.apply(new Context(), {
+ dshHome: '/global-dsh-home',
+ workspaceContext: false,
+ skills: { local: { dshHome: '/nested-dsh-home' } },
+ })
+ }).toThrow(/must resolve to the same directory/)
+ })
+
it('places workspace instructions before the skill catalog in the session prefix', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-prefix-order-'))
try {
@@ -307,6 +341,7 @@ describe('dsh-agent-spine-demo bundle', () => {
persona: 'You are merged.',
toolOrder: ['zulu'],
tools: { mode: 'native' as const },
+ dshHome: '/tmp/dsh-home',
workspaceContext: false as const,
skills: {},
toolBash: { enableRunInBackground: false },
@@ -317,6 +352,7 @@ describe('dsh-agent-spine-demo bundle', () => {
persona: appConfig.persona,
toolOrder: appConfig.toolOrder,
tools: appConfig.tools,
+ dshHome: appConfig.dshHome,
workspaceContext: false,
skills: {},
toolBash: appConfig.toolBash,
diff --git a/packages/examples/agent-spine-demo/tsconfig.json b/packages/examples/agent-spine-demo/tsconfig.json
index 9a51ffa8c8..89cb2accd8 100644
--- a/packages/examples/agent-spine-demo/tsconfig.json
+++ b/packages/examples/agent-spine-demo/tsconfig.json
@@ -50,6 +50,9 @@
{
"path": "../../support/invariants"
},
+ {
+ "path": "../../util/home"
+ },
{
"path": "../../bash/tool-bash"
},
diff --git a/packages/examples/stdio-demo/README.md b/packages/examples/stdio-demo/README.md
index d469acf6c4..5719eaff9f 100644
--- a/packages/examples/stdio-demo/README.md
+++ b/packages/examples/stdio-demo/README.md
@@ -28,6 +28,7 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte
| `model` | (required) | the pre-created `main` agent's model |
| `persona` | — | the deployment persona template (may reference `{{model}}`/`{{cwd}}`), routed to `dsh-system-prompt` |
| `toolOrder` | — | explicit model-facing tool order (a name list with one `''` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` |
+| `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home exposed to model bash and used by local skill discovery |
| `tools` | `{ mode: 'native' }` | tool-registry presentation config (`native` / `code` / `both`), routed through `dsh-agent-spine-demo` |
| `skills` | owner defaults | registry-cache, local-provider, and model-facing skill-tool config, routed through `dsh-agent-spine-demo` |
| `toolBash` | owner defaults | model-facing bash config routed through `dsh-agent-spine-demo`, including bash's producer-local `enableRunInBackground` |
diff --git a/packages/examples/stdio-demo/src/index.ts b/packages/examples/stdio-demo/src/index.ts
index d7ae866b53..04c06ad5db 100644
--- a/packages/examples/stdio-demo/src/index.ts
+++ b/packages/examples/stdio-demo/src/index.ts
@@ -43,6 +43,8 @@ export interface Config {
toolOrder?: string[]
/** Tool-registry config — its presentation `mode` (forwarded through agent-spine-demo; see dsh-tools). */
tools?: ToolsConfig
+ /** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */
+ dshHome?: string
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
persistenceRoot?: string
/** stdin-chat banner printed once on start. Defaults to `'ready.'`. */
@@ -71,6 +73,7 @@ export const Config: z = z.object({
// schemastery's native [] default would read as an invalid configured list.
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
tools: ToolRegistry.Config,
+ dshHome: z.string(),
// TODO(single-default-literal): share these schema defaults and defensive
// apply() fallbacks through named constants while retaining both boundaries.
persistenceRoot: z.string().default('./.sessions'),
diff --git a/packages/examples/stdio-demo/tests/stdio-agent.spec.ts b/packages/examples/stdio-demo/tests/stdio-agent.spec.ts
index 1dd9fda575..c0557ca512 100644
--- a/packages/examples/stdio-demo/tests/stdio-agent.spec.ts
+++ b/packages/examples/stdio-demo/tests/stdio-agent.spec.ts
@@ -132,8 +132,9 @@ describe('dsh-stdio-demo app', () => {
await ctx.fiber.dispose()
})
- it('forwards skill config into agent-spine-demo', async () => {
- const ctx = await mount({ model: 'mock', persona: 'hi', skills: await isolatedSkillsConfig(6), workspaceContext: false })
+ it('forwards skill config and dshHome into agent-spine-demo', async () => {
+ const skills = await isolatedSkillsConfig(6)
+ const ctx = await mount({ model: 'mock', persona: 'hi', dshHome: skills.local!.dshHome!, skills, workspaceContext: false })
ctx.skills.register({ name: 'stdio-skill', description: 'Stdio skill', source: 'runtime', content: 'body' })
expect(JSON.stringify(await composePrefix(ctx))).toContain('- `stdio-skill`: Std...')
await ctx.fiber.dispose()
diff --git a/packages/hooks/hooks-claude/README.md b/packages/hooks/hooks-claude/README.md
index 700acadda8..4430f5511a 100644
--- a/packages/hooks/hooks-claude/README.md
+++ b/packages/hooks/hooks-claude/README.md
@@ -46,6 +46,8 @@ The three emit points run detached — no seam awaits a `SessionStart`/`Subagent
The matcher subject is the tool name (`PreToolUse`/`PostToolUse`), the session source (`SessionStart`), or a constant `agent_type` of `general-purpose` (`SubagentStart`/`SubagentStop` — the harness subagent seam carries no per-kind label, so the bridge reports Claude Code's own Task-tool default; a default/`*`/empty `agent_type` matcher fires, a specific-kind matcher does not); `UserPromptSubmit`/`Stop` ignore matchers. Multiple file-configured hooks on one point run **serially, in config order**, and fold most-restrictively (`deny > ask > allow`, see `dsh-hook-protocol`); serial keeps each hook's `hook/invoked`/`hook/result` pair adjacent in the log, and the fold is order-independent for the decision (see the RFC's "run serially, not concurrently" note).
+Every agent-scoped stdin payload carries `session_id` and string-shaped `transcript_path`. The bridge resolves the latter through `ctx.sessionPersistence.locate(session.header)` when available and otherwise sends `''`. Lookup does not create or flush the artifact, so a path can be absent before the first turn-end checkpoint or omit the current open turn.
+
## Context source
Injected context carries an explicit `{ kind: 'plugin', plugin: 'hooks-claude' }` source. `agent.inject()` defaults a missing source to `{ kind: 'user' }`, which would mislabel plugin context as a user prompt — so the bridge always names itself.
diff --git a/packages/hooks/hooks-claude/package.json b/packages/hooks/hooks-claude/package.json
index abb68a061e..c6870a6471 100644
--- a/packages/hooks/hooks-claude/package.json
+++ b/packages/hooks/hooks-claude/package.json
@@ -29,6 +29,7 @@
"@deepseek-ai/dsh-hook-protocol": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
+ "@deepseek-ai/dsh-session-persistence": "^0.0.1",
"@deepseek-ai/dsh-subagent": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.7"
@@ -42,6 +43,8 @@
"@deepseek-ai/dsh-hook-protocol": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
+ "@deepseek-ai/dsh-session-persistence": "workspace:^",
+ "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.7"
diff --git a/packages/hooks/hooks-claude/src/index.ts b/packages/hooks/hooks-claude/src/index.ts
index 25f959260c..121e5cf75a 100644
--- a/packages/hooks/hooks-claude/src/index.ts
+++ b/packages/hooks/hooks-claude/src/index.ts
@@ -14,6 +14,7 @@ import type { Context } from 'cordis'
import z from 'schemastery'
import type { Agent, ContinuationDecision, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
+import type {} from '@deepseek-ai/dsh-session-persistence'
import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import {
appendHookInvoked,
@@ -197,7 +198,7 @@ export function apply(ctx: Context, config: Config): void {
// may miss the first request.
// TODO(session-start-gating): add a startup gate before promising first-turn delivery.
ctx.on('agent/session-start', (agent, source) => {
- detached.track(runPoint('SessionStart', source, sessionStartPayload(agent, source), { agent, signal: detached.signal })
+ detached.track(runPoint('SessionStart', source, sessionStartPayload(ctx, agent, source), { agent, signal: detached.signal })
.then((merged) => {
const context = contextFrom(merged)
if (context) agent.inject(context.content, { source: context.source })
@@ -211,7 +212,7 @@ export function apply(ctx: Context, config: Config): void {
// matcher subject (CC ignores matchers for this event). ---
ctx.on('agent/prompt-submit', async (agent, content, _source, next): Promise => {
const turn = lastTurn(agent)
- const merged = await runPoint('UserPromptSubmit', '', promptPayload(agent, content), { agent, turn })
+ const merged = await runPoint('UserPromptSubmit', '', promptPayload(ctx, agent, content), { agent, turn })
if (merged.decision === 'deny') {
return { kind: 'block', reason: merged.reason ?? 'blocked by UserPromptSubmit hook' }
}
@@ -230,7 +231,7 @@ export function apply(ctx: Context, config: Config): void {
// --- PreToolUse → PreToolDecision. Matcher subject is the tool name. ---
ctx.on('tools/pre-execute', async (exec, next): Promise => {
const turn = lastTurn(exec.agent)
- const merged = await runPoint('PreToolUse', exec.name, preToolPayload(exec), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} })
+ const merged = await runPoint('PreToolUse', exec.name, preToolPayload(ctx, exec), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} })
if (merged.decision === 'deny') return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' }
if (merged.decision === 'ask') return { kind: 'ask', ...merged.reason !== undefined ? { reason: merged.reason } : {} }
return next()
@@ -239,7 +240,7 @@ export function apply(ctx: Context, config: Config): void {
// --- PostToolUse → PostToolDecision. Matcher subject is the tool name. ---
ctx.on('tools/post-execute', async (exec, result, next): Promise => {
const turn = lastTurn(exec.agent)
- const merged = await runPoint('PostToolUse', exec.name, postToolPayload(exec, result), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} })
+ const merged = await runPoint('PostToolUse', exec.name, postToolPayload(ctx, exec, result), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} })
const context = contextFrom(merged)
if (merged.decision === 'deny') {
return { kind: 'block', feedback: [{ type: 'text', text: merged.reason ?? 'blocked by PostToolUse hook' }], ...context ? { additionalContexts: [context] } : {} }
@@ -261,7 +262,7 @@ export function apply(ctx: Context, config: Config): void {
// A blocking Stop hook forces continuation with its reason.
// TODO(stop-loop-guard): cap consecutive forced continuations; hooks must self-limit meanwhile.
ctx.on('agent/turn-continuation', async (agent, turn, _default, next): Promise => {
- const merged = await runPoint('Stop', '', stopPayload(agent), { agent, turn })
+ const merged = await runPoint('Stop', '', stopPayload(ctx, agent), { agent, turn })
if (merged.decision === 'deny') {
// A blocking Stop hook forces continuation.
const text = merged.reason ?? 'continue: blocked by Stop hook'
@@ -274,7 +275,7 @@ export function apply(ctx: Context, config: Config): void {
// use the live child's workspace and the generic agent-type matcher subject.
ctx.on('subagent/start', (info) => {
const child = ctx.get('agents')?.get(info.id)
- detached.track(runPoint('SubagentStart', SUBAGENT_TYPE, subagentPayload('SubagentStart', info, child), { ...child ? { agent: child } : {}, signal: detached.signal })
+ detached.track(runPoint('SubagentStart', SUBAGENT_TYPE, subagentPayload(ctx, 'SubagentStart', info, child), { ...child ? { agent: child } : {}, signal: detached.signal })
.then((merged) => {
const context = contextFrom(merged)
if (context && child) child.inject(context.content, { source: context.source })
@@ -286,7 +287,7 @@ export function apply(ctx: Context, config: Config): void {
// `.then` before the tool caller's `await run.result` disposes it) so the hook runs in the
// child's cwd, not the server default.
const child = ctx.get('agents')?.get(info.id)
- detached.track(runPoint('SubagentStop', SUBAGENT_TYPE, subagentPayload('SubagentStop', info, child), { ...child ? { agent: child } : {}, signal: detached.signal }))
+ detached.track(runPoint('SubagentStop', SUBAGENT_TYPE, subagentPayload(ctx, 'SubagentStop', info, child), { ...child ? { agent: child } : {}, signal: detached.signal }))
})
}
@@ -316,28 +317,31 @@ function blocksToText(content: ContentBlock[]): string {
return content.filter((b): b is Extract => b.type === 'text').map(b => b.text).join('')
}
-function base(agent: Agent | undefined, event: string): Record {
+function base(ctx: Context, agent: Agent | undefined, event: string): Record {
return {
session_id: agent?.session.header.id ?? '',
+ transcript_path: agent === undefined
+ ? ''
+ : ctx.get('sessionPersistence')?.locate(agent.session.header)?.path ?? '',
cwd: agent?.session.header.cwd ?? process.cwd(),
hook_event_name: event,
}
}
-function sessionStartPayload(agent: Agent, source: string): Record {
- return { ...base(agent, 'SessionStart'), source }
+function sessionStartPayload(ctx: Context, agent: Agent, source: string): Record {
+ return { ...base(ctx, agent, 'SessionStart'), source }
}
-function promptPayload(agent: Agent, content: ContentBlock[]): Record {
- return { ...base(agent, 'UserPromptSubmit'), prompt: blocksToText(content) }
+function promptPayload(ctx: Context, agent: Agent, content: ContentBlock[]): Record {
+ return { ...base(ctx, agent, 'UserPromptSubmit'), prompt: blocksToText(content) }
}
-function preToolPayload(exec: ToolExecution): Record {
- return { ...base(exec.agent, 'PreToolUse'), tool_name: exec.name, tool_input: exec.arguments, tool_use_id: exec.callId }
+function preToolPayload(ctx: Context, exec: ToolExecution): Record {
+ return { ...base(ctx, exec.agent, 'PreToolUse'), tool_name: exec.name, tool_input: exec.arguments, tool_use_id: exec.callId }
}
-function postToolPayload(exec: ToolExecution, result: ToolExecutionResult): Record {
- return { ...base(exec.agent, 'PostToolUse'), tool_name: exec.name, tool_input: exec.arguments, tool_use_id: exec.callId, tool_response: blocksToText(result.content) }
+function postToolPayload(ctx: Context, exec: ToolExecution, result: ToolExecutionResult): Record {
+ return { ...base(ctx, exec.agent, 'PostToolUse'), tool_name: exec.name, tool_input: exec.arguments, tool_use_id: exec.callId, tool_response: blocksToText(result.content) }
}
-function stopPayload(agent: Agent): Record {
- return { ...base(agent, 'Stop'), stop_hook_active: false }
+function stopPayload(ctx: Context, agent: Agent): Record {
+ return { ...base(ctx, agent, 'Stop'), stop_hook_active: false }
}
/**
* Build a SubagentStart/SubagentStop payload from the CC base (the child's
@@ -345,9 +349,9 @@ function stopPayload(agent: Agent): Record {
* fields. `agent_type` is the CC-default {@link SUBAGENT_TYPE}; `stop_hook_active`
* is present on SubagentStop only (the loop-guard flag, always false this cut).
*/
-function subagentPayload(event: 'SubagentStart' | 'SubagentStop', info: { id: string }, child: Agent | undefined): Record {
+function subagentPayload(ctx: Context, event: 'SubagentStart' | 'SubagentStop', info: { id: string }, child: Agent | undefined): Record {
return {
- ...base(child, event),
+ ...base(ctx, child, event),
agent_id: info.id,
agent_type: SUBAGENT_TYPE,
...event === 'SubagentStop' ? { stop_hook_active: false } : {},
diff --git a/packages/hooks/hooks-claude/tests/coverage.spec.ts b/packages/hooks/hooks-claude/tests/coverage.spec.ts
index 2a0444c422..4af01686ac 100644
--- a/packages/hooks/hooks-claude/tests/coverage.spec.ts
+++ b/packages/hooks/hooks-claude/tests/coverage.spec.ts
@@ -1,9 +1,10 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
-import { mkdtempSync, rmSync, writeFileSync, chmodSync, existsSync } from 'node:fs'
+import { mkdtempSync, rmSync, writeFileSync, chmodSync, existsSync, readFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
+import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { AgentId } from '@deepseek-ai/dsh-agent'
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
@@ -26,10 +27,11 @@ function hooks(d: string, h: unknown): string {
writeFileSync(join(d, 'hooks.json'), JSON.stringify({ hooks: h })); return join(d, 'hooks.json')
}
-type HarnessOpts = { pluginRoot?: string; projectDir?: string; stderrSummaryMaxChars?: number }
+type HarnessOpts = { pluginRoot?: string; projectDir?: string; stderrSummaryMaxChars?: number; sessionRoot?: string }
async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOpts = {}): Promise {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
+ if (opts.sessionRoot !== undefined) await ctx.plugin(SessionPersistenceJsonl, { root: opts.sessionRoot })
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
await ctx.plugin(HooksClaude, { configPath, ...opts })
@@ -51,6 +53,28 @@ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10):
}
describe('hooks-claude coverage — config option arms + substitution + skip warning', () => {
+ it('uses the persistence locator for transcript_path and an empty string without one', async () => {
+ async function capture(sessionRoot?: string): Promise<{ payload: { transcript_path: string }; expected: string | undefined }> {
+ const d = dir()
+ const cap = join(d, 'payload')
+ const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'capture.sh', `#!/usr/bin/env bash\ncat > "${cap}"\n`) }] }] })
+ const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
+ const ctx = await harness(path, adapter, { ...sessionRoot !== undefined ? { sessionRoot } : {} })
+ ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
+ const agent = ctx.agentLoop.create(AgentId('transcript'), { model: 'mock' })
+ agent.send([{ type: 'text', text: 'go' }])
+ await waitForIdle(ctx, agent)
+ return {
+ payload: JSON.parse(readFileSync(cap, 'utf8')) as { transcript_path: string },
+ expected: ctx.get('sessionPersistence')?.locate(agent.session.header)?.path,
+ }
+ }
+
+ const located = await capture(dir())
+ expect(located.payload.transcript_path).toBe(located.expected)
+ expect((await capture()).payload.transcript_path).toBe('')
+ }, 15_000) // Two real agent/hook subprocess loops need loaded pre-push runner headroom.
+
it('honors pluginRoot + projectDir substitution and warns on a skipped non-command hook', async () => {
const d = dir()
// ${CLAUDE_PLUGIN_ROOT} resolves to d; the script writes its own cwd-independent marker.
diff --git a/packages/hooks/hooks-claude/tsconfig.json b/packages/hooks/hooks-claude/tsconfig.json
index 909db9b5c3..07c88610f9 100644
--- a/packages/hooks/hooks-claude/tsconfig.json
+++ b/packages/hooks/hooks-claude/tsconfig.json
@@ -29,6 +29,9 @@
{
"path": "../../core/session"
},
+ {
+ "path": "../../session-persistence/session-persistence"
+ },
{
"path": "../../subagent/subagent"
},
diff --git a/packages/hooks/hooks-codex/README.md b/packages/hooks/hooks-codex/README.md
index 078bbc2e9d..0784857286 100644
--- a/packages/hooks/hooks-codex/README.md
+++ b/packages/hooks/hooks-codex/README.md
@@ -48,6 +48,8 @@ The hooks themselves run in the agent's session workspace: for the agent-scoped
A tool call's payload carries the real `tool_name` (the same value the matcher tests) and Codex's `tool_input: { command }` shape (the `command` arg when present, else `''`). The matcher subject is the tool name (`PreToolUse`/`PostToolUse`) or the session source (`SessionStart`); `UserPromptSubmit`/`Stop` ignore matchers.
+Every agent-scoped stdin payload carries `session_id` and `transcript_path`. The bridge resolves the latter through `ctx.sessionPersistence.locate(session.header)` when available and otherwise sends `null`, preserving the Codex `string | null` shape. Lookup does not create or flush the artifact, so a path can be absent before the first turn-end checkpoint or omit the current open turn.
+
`SessionStart` — the one emit point — runs detached; each run chain is tracked, and disposing the bridge aborts a still-running hook process, then drains the continuation before the dispose resolves (`createDetachedRuns` in `dsh-hook-protocol`).
## Context source
diff --git a/packages/hooks/hooks-codex/package.json b/packages/hooks/hooks-codex/package.json
index a481162a11..8c8686c539 100644
--- a/packages/hooks/hooks-codex/package.json
+++ b/packages/hooks/hooks-codex/package.json
@@ -29,6 +29,7 @@
"@deepseek-ai/dsh-hook-protocol": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
+ "@deepseek-ai/dsh-session-persistence": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
@@ -41,6 +42,8 @@
"@deepseek-ai/dsh-hook-protocol": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
+ "@deepseek-ai/dsh-session-persistence": "workspace:^",
+ "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts
index ad240e2e4d..8fa89a469d 100644
--- a/packages/hooks/hooks-codex/src/index.ts
+++ b/packages/hooks/hooks-codex/src/index.ts
@@ -17,6 +17,7 @@ import type { Context } from 'cordis'
import z from 'schemastery'
import type { Agent, ContinuationDecision, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
+import type {} from '@deepseek-ai/dsh-session-persistence'
import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import {
appendHookInvoked,
@@ -172,7 +173,7 @@ export function apply(ctx: Context, config: Config): void {
// hook may miss the first request.
// TODO(session-start-gating): add a startup gate before promising first-turn delivery.
ctx.on('agent/session-start', (agent, source) => {
- detached.track(runPoint('SessionStart', source, { ...base(agent, 'SessionStart', model), source }, { agent, plainStdoutAsContext: true, signal: detached.signal })
+ detached.track(runPoint('SessionStart', source, { ...base(ctx, agent, 'SessionStart', model), source }, { agent, plainStdoutAsContext: true, signal: detached.signal })
.then((merged) => {
const context = contextFrom(merged)
if (context) agent.inject(context.content, { source: context.source })
@@ -184,7 +185,7 @@ export function apply(ctx: Context, config: Config): void {
// UserPromptSubmit → PromptDecision. Codex supports block, not allow or ask.
ctx.on('agent/prompt-submit', async (agent, content, _source, next): Promise => {
const turn = lastTurn(agent)
- const merged = await runPoint('UserPromptSubmit', '', { ...turnBase(agent, 'UserPromptSubmit', model), prompt: blocksToText(content) }, { agent, turn, plainStdoutAsContext: true })
+ const merged = await runPoint('UserPromptSubmit', '', { ...turnBase(ctx, agent, 'UserPromptSubmit', model), prompt: blocksToText(content) }, { agent, turn, plainStdoutAsContext: true })
/* jscpd:ignore-start */
if (merged.decision === 'deny') return { kind: 'block', reason: merged.reason ?? 'blocked by UserPromptSubmit hook' }
// Context alone is not a veto: DELEGATE so a later prompt-submit listener can
@@ -202,7 +203,7 @@ export function apply(ctx: Context, config: Config): void {
// PreToolUse → PreToolDecision. Codex blocks only (no allow/ask honored).
ctx.on('tools/pre-execute', async (exec, next): Promise => {
const turn = lastTurn(exec.agent)
- const merged = await runPoint('PreToolUse', exec.name, preToolPayload(exec, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} })
+ const merged = await runPoint('PreToolUse', exec.name, preToolPayload(ctx, exec, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} })
/* jscpd:ignore-end */
if (merged.decision === 'deny') return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' }
return next()
@@ -212,7 +213,7 @@ export function apply(ctx: Context, config: Config): void {
ctx.on('tools/post-execute', async (exec, result, next): Promise => {
const turn = lastTurn(exec.agent)
/* jscpd:ignore-start */
- const merged = await runPoint('PostToolUse', exec.name, postToolPayload(exec, result, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} })
+ const merged = await runPoint('PostToolUse', exec.name, postToolPayload(ctx, exec, result, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} })
const context = contextFrom(merged)
if (merged.decision === 'deny') {
return { kind: 'block', feedback: [{ type: 'text', text: merged.reason ?? 'blocked by PostToolUse hook' }], ...context ? { additionalContexts: [context] } : {} }
@@ -236,7 +237,7 @@ export function apply(ctx: Context, config: Config): void {
// avoid continuing the same turn indefinitely. It is always false here, so an
// unconditionally blocking hook force-continues every step until it self-limits.
ctx.on('agent/turn-continuation', async (agent, turn, _default, next): Promise => {
- const merged = await runPoint('Stop', '', { ...turnBase(agent, 'Stop', model), stop_hook_active: false, last_assistant_message: null }, { agent, turn })
+ const merged = await runPoint('Stop', '', { ...turnBase(ctx, agent, 'Stop', model), stop_hook_active: false, last_assistant_message: null }, { agent, turn })
/* jscpd:ignore-end */
if (merged.decision === 'deny') {
// A blocking Stop hook forces continuation; a block with no reason (exit 2,
@@ -270,10 +271,12 @@ function blocksToText(content: ContentBlock[]): string {
/* jscpd:ignore-end */
/** Base fields on every Codex payload (no turn_id). */
-function base(agent: Agent | undefined, event: string, model: string): Record {
+function base(ctx: Context, agent: Agent | undefined, event: string, model: string): Record {
return {
session_id: agent?.session.header.id ?? '',
- transcript_path: null,
+ transcript_path: agent === undefined
+ ? null
+ : ctx.get('sessionPersistence')?.locate(agent.session.header)?.path ?? null,
cwd: agent?.session.header.cwd ?? process.cwd(),
hook_event_name: event,
model,
@@ -282,8 +285,8 @@ function base(agent: Agent | undefined, event: string, model: string): Record {
- return { ...base(agent, event, model), turn_id: String(lastTurn(agent)) }
+function turnBase(ctx: Context, agent: Agent | undefined, event: string, model: string): Record {
+ return { ...base(ctx, agent, event, model), turn_id: String(lastTurn(agent)) }
}
/** Extract a `command` string from a tool call's parsed arguments, else ''. */
@@ -295,14 +298,14 @@ function commandOf(args: unknown): string {
return ''
}
-function preToolPayload(exec: ToolExecution, model: string): Record {
+function preToolPayload(ctx: Context, exec: ToolExecution, model: string): Record {
// `tool_name` is the REAL tool name (matching the `exec.name` matcher subject);
// a hardcoded constant would disagree with what the matcher tests and make a
// config's tool matcher never fire. `tool_input` keeps Codex's `{ command }`
// shape (its shell payload), derived from the call's `command` arg when present.
- return { ...turnBase(exec.agent, 'PreToolUse', model), tool_name: exec.name, tool_input: { command: commandOf(exec.arguments) }, tool_use_id: exec.callId }
+ return { ...turnBase(ctx, exec.agent, 'PreToolUse', model), tool_name: exec.name, tool_input: { command: commandOf(exec.arguments) }, tool_use_id: exec.callId }
}
-function postToolPayload(exec: ToolExecution, result: ToolExecutionResult, model: string): Record {
- return { ...turnBase(exec.agent, 'PostToolUse', model), tool_name: exec.name, tool_input: { command: commandOf(exec.arguments) }, tool_use_id: exec.callId, tool_response: blocksToText(result.content) }
+function postToolPayload(ctx: Context, exec: ToolExecution, result: ToolExecutionResult, model: string): Record {
+ return { ...turnBase(ctx, exec.agent, 'PostToolUse', model), tool_name: exec.name, tool_input: { command: commandOf(exec.arguments) }, tool_use_id: exec.callId, tool_response: blocksToText(result.content) }
}
diff --git a/packages/hooks/hooks-codex/tests/coverage.spec.ts b/packages/hooks/hooks-codex/tests/coverage.spec.ts
index d262b51dbb..e2d65967a7 100644
--- a/packages/hooks/hooks-codex/tests/coverage.spec.ts
+++ b/packages/hooks/hooks-codex/tests/coverage.spec.ts
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
+import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { AgentId } from '@deepseek-ai/dsh-agent'
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
@@ -22,9 +23,11 @@ function hooks(d: string, h: unknown): string {
writeFileSync(join(d, 'hooks.json'), JSON.stringify({ hooks: h })); return join(d, 'hooks.json')
}
-async function harness(configPath: string, adapter: MockAdapter, opts: { stderrSummaryMaxChars?: number } = {}): Promise {
+type HarnessOpts = { stderrSummaryMaxChars?: number; sessionRoot?: string }
+async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOpts = {}): Promise {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
+ if (opts.sessionRoot !== undefined) await ctx.plugin(SessionPersistenceJsonl, { root: opts.sessionRoot })
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
await ctx.plugin(HooksCodex, { configPath, model: 'm', ...opts })
@@ -46,6 +49,28 @@ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10):
}
describe('hooks-codex coverage — decision mapping paths', () => {
+ it('uses the persistence locator for transcript_path and null without one', async () => {
+ async function capture(sessionRoot?: string): Promise<{ payload: { transcript_path: string | null }; expected: string | undefined }> {
+ const d = dir()
+ const cap = join(d, 'payload')
+ const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'capture.sh', `#!/usr/bin/env bash\ncat > "${cap}"\n`) }] }] })
+ const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
+ const ctx = await harness(path, adapter, { ...sessionRoot !== undefined ? { sessionRoot } : {} })
+ ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
+ const agent = ctx.agentLoop.create(AgentId('transcript'), { model: 'mock' })
+ agent.send([{ type: 'text', text: 'go' }])
+ await waitForIdle(ctx, agent)
+ return {
+ payload: JSON.parse(readFileSync(cap, 'utf8')) as { transcript_path: string | null },
+ expected: ctx.get('sessionPersistence')?.locate(agent.session.header)?.path,
+ }
+ }
+
+ const located = await capture(dir())
+ expect(located.payload.transcript_path).toBe(located.expected)
+ expect((await capture()).payload.transcript_path).toBeNull()
+ }, 15_000) // Two real agent/hook subprocess loops need loaded pre-push runner headroom.
+
it('UserPromptSubmit block (exit 2) → rejected turn; default reason on empty stderr', async () => {
const d = dir()
hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'b.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] })
diff --git a/packages/hooks/hooks-codex/tsconfig.json b/packages/hooks/hooks-codex/tsconfig.json
index f936b500aa..ae3c91e9dd 100644
--- a/packages/hooks/hooks-codex/tsconfig.json
+++ b/packages/hooks/hooks-codex/tsconfig.json
@@ -29,6 +29,9 @@
{
"path": "../../core/session"
},
+ {
+ "path": "../../session-persistence/session-persistence"
+ },
{
"path": "../../llm/llm"
},
diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md
index 343fb4a70a..43f4443107 100644
--- a/packages/session-persistence/session-persistence-jsonl/README.md
+++ b/packages/session-persistence/session-persistence-jsonl/README.md
@@ -19,6 +19,8 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence
|---|---|---|
| `root` | `string` (required) | Root directory for all session files. **No default** — a `process.cwd()` default would scatter files as the process's cwd changes (bash calls, subprocesses). |
+`locate(meta)` returns `{ kind: 'jsonl', path }` using the resolved absolute root and the same cwd-bucket/id encoding as materialization. It performs no filesystem I/O: the target can be returned before the file exists, and an existing file contains only the last flushed prefix.
+
## Durability and crash semantics
- **Lazy materialization.** `create(meta)` writes nothing; on the first `append`, the backend writes and `fsync`s a temporary file, publishes it without overwrite via a hard link, then `fsync`s the directory. A created-but-never-appended session leaves nothing on disk and is absent from `list`.
diff --git a/packages/session-persistence/session-persistence-jsonl/src/index.ts b/packages/session-persistence/session-persistence-jsonl/src/index.ts
index 1d13ff424e..0c8a2ee3ef 100644
--- a/packages/session-persistence/session-persistence-jsonl/src/index.ts
+++ b/packages/session-persistence/session-persistence-jsonl/src/index.ts
@@ -1,7 +1,8 @@
/**
* JSONL durable session-persistence backend. It stores a header and contiguous
* events in one append-only file per session, and delegates orchestration to
- * {@link PersistenceCoordinator}.
+ * {@link PersistenceCoordinator}. Its side-effect-free locator returns the
+ * absolute per-session log target before materialization.
* @module @deepseek-ai/dsh-session-persistence-jsonl
*/
@@ -12,7 +13,7 @@ import { dirname, resolve } from 'node:path'
import { randomBytes } from 'node:crypto'
import {
SessionPersistence, PersistenceCoordinator,
- type PersistenceBackend, type StoredPrefix,
+ type PersistenceBackend, type SessionLocation, type StoredPrefix,
} from '@deepseek-ai/dsh-session-persistence'
import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session'
import {
@@ -68,6 +69,11 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
/* jscpd:ignore-start */
// --- SessionPersistence service surface (delegated to the coordinator) ---
+ /** Resolve the absolute target path without touching the filesystem. */
+ locate(meta: SessionHeader): SessionLocation {
+ return { kind: 'jsonl', path: logPath(this.root, meta.cwd, meta.id) }
+ }
+
create(meta: SessionHeader): Promise {
return this.coordinator.create(meta)
}
diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts
index 75fcb48732..3c76616796 100644
--- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts
+++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat } from 'node:fs/promises'
import { tmpdir } from 'node:os'
-import { join } from 'node:path'
+import { isAbsolute, join, relative, resolve } from 'node:path'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
@@ -112,6 +112,19 @@ describe('SessionPersistenceJsonl: format helpers', () => {
it('encodeSegment rejects an empty id', () => {
expect(() => encodeSegment('')).toThrow(/empty/)
})
+
+ it('resolves a relative custom root before locating a session', async () => {
+ const absoluteRoot = await freshRoot()
+ const ctx = new Context()
+ await ctx.plugin(SessionStore)
+ const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: relative(process.cwd(), absoluteRoot) })
+ const m = meta('relative-location', '/work')
+ expect(ctx.sessionPersistence.locate(m)).toEqual({
+ kind: 'jsonl',
+ path: logPath(resolve(absoluteRoot), '/work', m.id),
+ })
+ await fiber.dispose()
+ })
})
describe('SessionPersistenceJsonl: durability and crash semantics', () => {
@@ -126,8 +139,13 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
it('lazy materialization: create() writes no file until the first append', async () => {
const m = meta('lazy', '/work')
+ const location = ctx.sessionPersistence.locate(m)
+ expect(location).toEqual({ kind: 'jsonl', path: logPath(root, '/work', m.id) })
+ expect(isAbsolute(location!.path)).toBe(true)
+
await ctx.sessionPersistence.create(m)
- // nothing on disk yet
+ // locate() is a pure target-path calculation: neither it nor create()
+ // materializes a file before the first append.
const dir = sessionDir(root, '/work')
await expect(stat(logPath(root, '/work', m.id))).rejects.toThrow()
expect((await ctx.sessionPersistence.list()).map(h => h.id)).not.toContain(m.id)
@@ -139,6 +157,26 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
void dir
})
+ it('keeps the same location on resume and gives a fork its own location', async () => {
+ const parent = meta('location-parent', '/work')
+ const parentLocation = ctx.sessionPersistence.locate(parent)
+ await ctx.sessionPersistence.create(parent)
+ await ctx.sessionPersistence.append(parent.id, oneTurnLog())
+
+ const loaded = await ctx.sessionPersistence.load(parent.id)
+ expect(ctx.sessionPersistence.locate(loaded.meta)).toEqual(parentLocation)
+
+ const child = {
+ ...loaded.meta,
+ id: SessionId('location-child'),
+ parentSession: parent.id,
+ seedLength: loaded.events.length,
+ }
+ const childLocation = ctx.sessionPersistence.locate(child)
+ expect(childLocation?.path).not.toBe(parentLocation?.path)
+ expect(childLocation).toEqual({ kind: 'jsonl', path: logPath(root, '/work', child.id) })
+ })
+
it('round-trip is byte-identical (incl. assistant/chunk verbatim)', async () => {
const m = meta('chunks')
const log: SessionEvent[] = [
diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md
index 1411e177be..241e7ceb38 100644
--- a/packages/session-persistence/session-persistence-sqlite/README.md
+++ b/packages/session-persistence/session-persistence-sqlite/README.md
@@ -2,6 +2,8 @@
A SQLite durable session-persistence backend — a second `SessionPersistence` implementation ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), built to validate that the abstract seam and the shared `runPersistenceContract` suite are genuinely backend-agnostic. It satisfies the SAME contract as `dsh-session-persistence-jsonl` (append-only, contiguous-seq, lazy materialization, interrupted-turn close on load), expressed over `node:sqlite` rows instead of file bytes.
+`locate(meta)` returns `undefined`: all sessions share one database, so there is no honest independent per-session transcript path.
+
> **TODO:** this backend talks to `node:sqlite` directly. If a cordis database service (`cordis/db` / a `@cordisjs` SQL driver plugin) is adopted, route through that instead of holding a raw `DatabaseSync` here — the contract surface (`SessionPersistence`) would not change, only the storage driver.
## Storage model
diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts
index 7d23292e9d..d886124961 100644
--- a/packages/session-persistence/session-persistence-sqlite/src/index.ts
+++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts
@@ -1,7 +1,8 @@
/**
* SQLite durable session-persistence backend. It maps each session header and
* event to rows, and delegates write-path orchestration to
- * {@link PersistenceCoordinator}.
+ * {@link PersistenceCoordinator}. It has no independent per-session artifact,
+ * so its locator returns `undefined`.
* @module @deepseek-ai/dsh-session-persistence-sqlite
*/
@@ -12,7 +13,7 @@ import { mkdir } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import {
SessionPersistence, PersistenceCoordinator,
- type PersistenceBackend, type StoredPrefix,
+ type PersistenceBackend, type SessionLocation, type StoredPrefix,
} from '@deepseek-ai/dsh-session-persistence'
import type { SessionEvent, SurfaceEventType, SessionId, SessionHeader } from '@deepseek-ai/dsh-session'
import {
@@ -95,6 +96,11 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
// --- SessionPersistence service surface (delegated to the coordinator) ---
+ /** SQLite has one database, not an independent local artifact per session. */
+ locate(_meta: SessionHeader): SessionLocation | undefined {
+ return undefined
+ }
+
create(meta: SessionHeader): Promise {
return this.coordinator.create(meta)
}
diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts
index bae79b0a53..81316b9d2a 100644
--- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts
+++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts
@@ -153,6 +153,12 @@ describe('scanRows', () => {
})
describe('SessionPersistenceSqlite: durability and crash semantics', () => {
+ it('has no independent per-session log location', async () => {
+ const { ctx, dispose } = await backend()
+ expect(ctx.sessionPersistence.locate(meta('sqlite-location'))).toBeUndefined()
+ await dispose()
+ })
+
it('an interrupted turn (rows after the last turn/end) is PRESERVED and closed during load', async () => {
const path = await freshDbPath()
const m = meta('crash')
diff --git a/packages/session-persistence/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md
index f09a21251b..a294575fd9 100644
--- a/packages/session-persistence/session-persistence/README.md
+++ b/packages/session-persistence/session-persistence/README.md
@@ -8,6 +8,7 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l
| Method | Contract |
|---|---|
+| `locate(meta): SessionLocation \| undefined` | Resolve an absolute per-session artifact target without I/O or materialization. Backends without an independent local artifact return `undefined`. |
| `create(meta): Promise` | Register a new session's metadata. MAY defer the physical write until the first `append` (lazy materialization). |
| `append(id, events): Promise` | Durably persist a batch (from the `session/flush` drain). Append-only; first event `seq` == stored next-seq after any repair; rejects non-JSON-serializable data naming the offending type. |
| `load(id): Promise<{ meta; events }>` | Reload meta + log. Preserves an interrupted (unclosed) final turn and closes it with synthetic closers — an error `tool/result` per unanswered `tool-call`, then `step/end?`+`turn/end {interrupted}` (a turn can be huge — never truncated); only a torn tail fragment is dropped. Events contiguous (`events[i].seq === i`); rejects a committed-region gap/parse error or unknown `version`. |
@@ -24,6 +25,8 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l
`PersistenceCoordinator` owns per-id state, write-behind buffers and serialization, the `session/event` → `session/flush` drain, lazy materialization, crash-tail repair, session adoption, and quiescent disposal. A first-party backend composes one, implements the small `PersistenceBackend` storage hook interface, and delegates its four public service methods. JSONL and SQLite therefore share lifecycle correctness while retaining different storage primitives; see the [coordinator RFC](../../../docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md).
+The side-effect-free `locate` query remains backend-owned because it describes storage topology rather than write orchestration.
+
The `PersistenceBackend` hooks (the only seam between the coordinator and storage):
| Hook | Role |
@@ -44,9 +47,9 @@ Import `runPersistenceContract` from `tests/contract.ts` (the public-API contrac
Three backends run these suites: an in-memory reference (in `tests/`), `dsh-session-persistence-jsonl` (append-only file log) and `dsh-session-persistence-sqlite` (`node:sqlite`, each `SessionEvent` one row `(session_id, seq, type, time, data, source_event_seqs, surface_op)`). All passing the same contract + coordinator suite is the proof that the seam is genuinely backend-agnostic — lazy materialization, crash-tail-on-load, and contiguous-seq hold identically over file bytes and over a transactional store.
-## Metadata types
+## Metadata and location types
-Re-exported from `dsh-session`: `SessionHeader` (immutable session metadata: `version`, `id`, `createdAt`, `cwd?`, `parentSession?`, `seedLength?`).
+Re-exported from `dsh-session`: `SessionHeader` (immutable session metadata: `version`, `id`, `createdAt`, `cwd?`, `parentSession?`, `seedLength?`). `SessionLocation` is `{ readonly kind: string; readonly path: string }`; its path is an absolute backend target, not proof that the artifact exists or contains an unflushed turn.
## Model Experience
diff --git a/packages/session-persistence/session-persistence/src/index.ts b/packages/session-persistence/session-persistence/src/index.ts
index 3e6c9c21ed..3c102e6ede 100644
--- a/packages/session-persistence/session-persistence/src/index.ts
+++ b/packages/session-persistence/session-persistence/src/index.ts
@@ -21,6 +21,18 @@ declare module 'cordis' {
}
}
+/**
+ * A backend-resolved, per-session local artifact location. The path is an
+ * absolute target path and can name an artifact that has not materialized yet.
+ * Consumers must treat it as a location hint, never as an authorization token.
+ */
+export interface SessionLocation {
+ /** Backend-specific artifact kind, for example `jsonl`. */
+ readonly kind: string
+ /** Absolute path to this session's backend-owned artifact. */
+ readonly path: string
+}
+
/**
* Durable append-only session storage. Implementations preserve contiguous,
* losslessly JSON-serializable events; {@link append} resolves only after
@@ -32,6 +44,15 @@ export abstract class SessionPersistence extends Service {
super(ctx, 'sessionPersistence')
}
+ /**
+ * Resolve this backend's independent local artifact for a session without
+ * reading, creating, flushing, or otherwise materializing it. Backends such
+ * as SQLite that do not own one artifact per session return `undefined`.
+ * @param meta - the immutable session header whose artifact is requested.
+ * @returns the backend-specific absolute location, when one exists.
+ */
+ abstract locate(meta: SessionHeader): SessionLocation | undefined
+
/**
* Register a new session's metadata. A backend MAY defer the physical write
* until the first {@link append} (lazy materialization), in which case a
diff --git a/packages/session-persistence/session-persistence/tests/persistence.spec.ts b/packages/session-persistence/session-persistence/tests/persistence.spec.ts
index a937a39da7..5a0ec2d3c3 100644
--- a/packages/session-persistence/session-persistence/tests/persistence.spec.ts
+++ b/packages/session-persistence/session-persistence/tests/persistence.spec.ts
@@ -41,6 +41,10 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend
// --- service surface (delegated to the coordinator) ---
+ locate(_meta: SessionHeader): undefined {
+ return undefined
+ }
+
create(m: SessionHeader): Promise {
return this.coordinator.create(m)
}
diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts
index bb7d01ec73..733b746a6a 100644
--- a/packages/session-query/session-query/tests/session-query.spec.ts
+++ b/packages/session-query/session-query/tests/session-query.spec.ts
@@ -34,6 +34,10 @@ class TestPersistence extends SessionPersistence {
this.afterList = undefined
}
+ locate(_meta: SessionHeader): undefined {
+ return undefined
+ }
+
create(meta: SessionHeader): Promise {
TestPersistence.entries.set(meta.id, { meta: structuredClone(meta), events: [] })
return Promise.resolve()
diff --git a/packages/session-query/session-query/tests/tracing.spec.ts b/packages/session-query/session-query/tests/tracing.spec.ts
index eb22a9f95c..d897a5bf67 100644
--- a/packages/session-query/session-query/tests/tracing.spec.ts
+++ b/packages/session-query/session-query/tests/tracing.spec.ts
@@ -44,6 +44,10 @@ class TracePersistence extends SessionPersistence {
this.afterList = undefined
}
+ locate(_meta: SessionHeader): undefined {
+ return undefined
+ }
+
create(meta: SessionHeader): Promise {
TracePersistence.entries.set(meta.id, { meta: structuredClone(meta), events: [] })
return Promise.resolve()
diff --git a/packages/skill/skill-local/README.md b/packages/skill/skill-local/README.md
index 9449a40ec5..8034441272 100644
--- a/packages/skill/skill-local/README.md
+++ b/packages/skill/skill-local/README.md
@@ -12,7 +12,7 @@ Requires `ctx.skills` (`inject: ['skills']`).
| Field | Default | Meaning |
|---|---|---|
-| `dshHome` | `$DSH_HOME` or `~/.dsh` | DeepSeek Harness config root; scans `skills` under this directory. |
+| `dshHome` | `$DSH_HOME` or `~/.dsh` | DeepSeek Harness config root resolved by [`@deepseek-ai/dsh-home`](../../util/home/README.md); scans `skills` under this directory. |
| `agentsHome` | `$DSH_AGENTS_HOME` or `~/.agents` | Shared agent config root scanned for compatible skills. |
| `customSkillDirs` | `[]` | Additional local skill roots scanned after project roots and before user roots. |
diff --git a/packages/skill/skill-local/package.json b/packages/skill/skill-local/package.json
index d1ca775a26..d490438c51 100644
--- a/packages/skill/skill-local/package.json
+++ b/packages/skill/skill-local/package.json
@@ -23,6 +23,7 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-fs": "^0.0.1",
+ "@deepseek-ai/dsh-home": "^0.0.1",
"@deepseek-ai/dsh-skill": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
@@ -32,6 +33,7 @@
},
"devDependencies": {
"@deepseek-ai/dsh-fs": "workspace:^",
+ "@deepseek-ai/dsh-home": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
diff --git a/packages/skill/skill-local/src/index.ts b/packages/skill/skill-local/src/index.ts
index 19a15f1de8..ee109fbb16 100644
--- a/packages/skill/skill-local/src/index.ts
+++ b/packages/skill/skill-local/src/index.ts
@@ -17,6 +17,7 @@ import z from 'schemastery'
import type Schema from 'schemastery'
import { parse as parseYaml } from 'yaml'
import type { FileSystem, FsDirEntry, FsTarget } from '@deepseek-ai/dsh-fs'
+import { resolveDshHome } from '@deepseek-ai/dsh-home'
import {
isSkillName,
type SkillCandidate,
@@ -92,7 +93,7 @@ export class LocalSkillProvider implements SkillProvider {
private readonly customSkillDirs: string[]
constructor(private readonly ctx: Context, config: Config = {}) {
- this.dshHome = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh'))
+ this.dshHome = resolveDshHome(config.dshHome)
this.agentsHome = resolve(config.agentsHome ?? process.env.DSH_AGENTS_HOME ?? join(homedir(), '.agents'))
this.customSkillDirs = (config.customSkillDirs ?? []).map(root => resolve(root))
}
diff --git a/packages/skill/skill-local/tsconfig.json b/packages/skill/skill-local/tsconfig.json
index 018f0a4a50..f51147abce 100644
--- a/packages/skill/skill-local/tsconfig.json
+++ b/packages/skill/skill-local/tsconfig.json
@@ -9,6 +9,7 @@
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../../../vendor/schemastery" },
+ { "path": "../../util/home" },
{ "path": "../../fs/fs" },
{ "path": "../skill" }
]
diff --git a/packages/util/README.md b/packages/util/README.md
index fe077d2624..4b4f23ddf7 100644
--- a/packages/util/README.md
+++ b/packages/util/README.md
@@ -5,12 +5,15 @@ Zero-dependency primitives shared across the other groups. A package lands here
| Package | Role |
|---|---|
| `brand/` | The type-only `Branded` nominal-typing primitive (no runtime code, no harness deps) |
+| `home/` | Canonical `DSH_HOME` resolution from explicit config, environment, or `~/.dsh` (no harness deps) |
| `paths/` | Shared filesystem path constants and helpers for harness user data |
| `timeout/` | The timing/classification half of a timeout — `clampTimeout`/`deadline`/`timeoutOf`/`TimeoutReason` (pure functions, no harness deps); termination stays in each capability |
| `retention/` | Bounded model-facing output — `ItemRetainer`/`TextRetainer` + neutral notice helpers (pure, no harness deps); business semantics stay in each tool |
`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`.
+`dsh-home` gives every package the same configurable Harness home without assigning that cross-cutting fact to bash, skills, or a composition bundle. It resolves an explicit value before `$DSH_HOME`, falls back to `~/.dsh`, and returns an absolute path without caching, creating, or mutating anything.
+
`dsh-timeout` follows the same shape for the timeout family: `dsh-bash` and `dsh-web-fetch-local` each fuse a caller's cancellation with a deadline and later classify "timed out" vs "cancelled" by depending on `dsh-timeout` alone. It deliberately owns only the timing/classification half — the *termination* (SIGKILL a process group, tear down a fetch socket) stays in each capability, because no shared layer can own every capability's kill (see [the timeout-library RFC](../../docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.md)).
`dsh-retention` is the same split for bounded tool output: a tool (`glob`/`grep`/`bash`/`web_fetch`/`web_search`) feeds items or text into a retainer and gets back what it kept and exactly what it omitted — while grouping, exit codes, provider errors, and recovery prose stay tool-owned. It deliberately owns only the retention mechanic; `truncated` is a budget fact, never an "incomplete inspection" state (see [the retention-library RFC](../../docs/rfc/implemented/architecture/2026-07-06-tool-result-retention-library.md)).
diff --git a/packages/util/home/README.md b/packages/util/home/README.md
new file mode 100644
index 0000000000..f9107a8706
--- /dev/null
+++ b/packages/util/home/README.md
@@ -0,0 +1,17 @@
+# @deepseek-ai/dsh-home
+
+`@deepseek-ai/dsh-home` is the single owner of DeepSeek Harness home-directory resolution. `resolveDshHome(configured?)` returns an absolute path using this precedence:
+
+1. The explicit `configured` path.
+2. The `DSH_HOME` environment variable.
+3. The `.dsh` directory under the current user's home directory.
+
+The resolver reads its inputs at call time. It does not cache a result, create the directory, or mutate `process.env`; consumers keep ownership of their own configuration fields and pass the configured value when resolving the shared home.
+
+## Model Experience
+
+Indirectly, through `dsh-tool-bash`, which exposes the resolved path to model bash as `DSH_HOME` without adding a prompt section.
+
+## Known Limitations and Deferred Work
+
+- **Resolution only** — the resolver makes a path absolute but does not create it, check access, or canonicalize symlinks; each consumer owns those filesystem decisions.
diff --git a/packages/util/home/package.json b/packages/util/home/package.json
new file mode 100644
index 0000000000..efeaf4832c
--- /dev/null
+++ b/packages/util/home/package.json
@@ -0,0 +1,30 @@
+{
+ "name": "@deepseek-ai/dsh-home",
+ "description": "Canonical DeepSeek Harness home-directory resolver",
+ "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": {
+ "cordis": "^4.0.0-rc.6"
+ },
+ "devDependencies": {
+ "cordis": "^4.0.0-rc.6"
+ }
+}
diff --git a/packages/util/home/src/index.ts b/packages/util/home/src/index.ts
new file mode 100644
index 0000000000..4e3d56b54b
--- /dev/null
+++ b/packages/util/home/src/index.ts
@@ -0,0 +1,23 @@
+/**
+ * Canonical DeepSeek Harness home-directory resolution.
+ *
+ * @module @deepseek-ai/dsh-home
+ */
+
+import { homedir } from 'node:os'
+import { join, resolve } from 'node:path'
+
+const DEFAULT_DSH_HOME_DIRNAME = '.dsh'
+
+/** Environment variable that overrides the default Harness home directory. */
+export const DSH_HOME_ENV = 'DSH_HOME' as const
+
+/**
+ * Resolve the DeepSeek Harness home directory without caching or mutating the environment.
+ *
+ * @param configured - Optional configured path, which takes precedence over the environment.
+ * @returns The absolute configured path, `$DSH_HOME`, or `~/.dsh`, in that order.
+ */
+export function resolveDshHome(configured?: string): string {
+ return resolve(configured ?? process.env[DSH_HOME_ENV] ?? join(homedir(), DEFAULT_DSH_HOME_DIRNAME))
+}
diff --git a/packages/util/home/tests/home.spec.ts b/packages/util/home/tests/home.spec.ts
new file mode 100644
index 0000000000..3ebde50bee
--- /dev/null
+++ b/packages/util/home/tests/home.spec.ts
@@ -0,0 +1,26 @@
+import { homedir } from 'node:os'
+import { join, resolve } from 'node:path'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-home'
+
+afterEach(() => vi.unstubAllEnvs())
+
+describe('resolveDshHome', () => {
+ it('prefers an explicit configured path and resolves it absolutely', () => {
+ vi.stubEnv(DSH_HOME_ENV, './environment-home')
+
+ expect(resolveDshHome('./configured-home')).toBe(resolve('./configured-home'))
+ })
+
+ it('uses DSH_HOME when no configured path is supplied', () => {
+ vi.stubEnv(DSH_HOME_ENV, './environment-home')
+
+ expect(resolveDshHome()).toBe(resolve('./environment-home'))
+ })
+
+ it('defaults to the .dsh directory under the user home', () => {
+ vi.stubEnv(DSH_HOME_ENV, undefined)
+
+ expect(resolveDshHome()).toBe(join(homedir(), '.dsh'))
+ })
+})
diff --git a/packages/util/home/tsconfig.json b/packages/util/home/tsconfig.json
new file mode 100644
index 0000000000..9770ef25d6
--- /dev/null
+++ b/packages/util/home/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "lib/types"
+ },
+ "include": ["src"],
+ "references": []
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f835f475ce..24b19fb52e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -164,6 +164,9 @@ importers:
'@deepseek-ai/dsh-bash-local':
specifier: workspace:^
version: link:../bash-local
+ '@deepseek-ai/dsh-home':
+ specifier: workspace:^
+ version: link:../../util/home
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
@@ -173,6 +176,12 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
+ '@deepseek-ai/dsh-session-persistence':
+ specifier: workspace:^
+ version: link:../../session-persistence/session-persistence
+ '@deepseek-ai/dsh-session-persistence-jsonl':
+ specifier: workspace:^
+ version: link:../../session-persistence/session-persistence-jsonl
'@deepseek-ai/dsh-system-prompt':
specifier: workspace:^
version: link:../../core/system-prompt
@@ -556,6 +565,9 @@ importers:
'@deepseek-ai/dsh-fs-local':
specifier: workspace:^
version: link:../../fs/fs-local
+ '@deepseek-ai/dsh-home':
+ specifier: workspace:^
+ version: link:../../util/home
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../support/invariants
@@ -853,6 +865,12 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
+ '@deepseek-ai/dsh-session-persistence':
+ specifier: workspace:^
+ version: link:../../session-persistence/session-persistence
+ '@deepseek-ai/dsh-session-persistence-jsonl':
+ specifier: workspace:^
+ version: link:../../session-persistence/session-persistence-jsonl
'@deepseek-ai/dsh-subagent':
specifier: workspace:^
version: link:../../subagent/subagent
@@ -893,6 +911,12 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
+ '@deepseek-ai/dsh-session-persistence':
+ specifier: workspace:^
+ version: link:../../session-persistence/session-persistence
+ '@deepseek-ai/dsh-session-persistence-jsonl':
+ specifier: workspace:^
+ version: link:../../session-persistence/session-persistence-jsonl
'@deepseek-ai/dsh-tools':
specifier: workspace:^
version: link:../../core/tools
@@ -1163,6 +1187,9 @@ importers:
'@deepseek-ai/dsh-fs':
specifier: workspace:^
version: link:../../fs/fs
+ '@deepseek-ai/dsh-home':
+ specifier: workspace:^
+ version: link:../../util/home
'@deepseek-ai/dsh-skill':
specifier: workspace:^
version: link:../skill
@@ -1918,6 +1945,12 @@ importers:
specifier: ^4.0.0-rc.7
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
+ packages/util/home:
+ devDependencies:
+ cordis:
+ specifier: ^4.0.0-rc.6
+ version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
+
packages/util/paths:
devDependencies:
cordis:
@@ -2204,6 +2237,9 @@ importers:
'@deepseek-ai/dsh-fs-policy':
specifier: workspace:^
version: link:../../packages/fs/fs-policy
+ '@deepseek-ai/dsh-home':
+ specifier: workspace:^
+ version: link:../../packages/util/home
'@deepseek-ai/dsh-hook-protocol':
specifier: workspace:^
version: link:../../packages/hooks/hook-protocol
diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json
index a9c0055a02..c456fae848 100644
--- a/python/sdk-runtime/package.json
+++ b/python/sdk-runtime/package.json
@@ -23,6 +23,7 @@
"@deepseek-ai/dsh-fs": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-fs-policy": "workspace:^",
+ "@deepseek-ai/dsh-home": "workspace:^",
"@deepseek-ai/dsh-hook-protocol": "workspace:^",
"@deepseek-ai/dsh-hooks-claude": "workspace:^",
"@deepseek-ai/dsh-hooks-codex": "workspace:^",
diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts
index 0a1b450b2e..1e182c37c0 100644
--- a/scripts/gen-doc-graphs.ts
+++ b/scripts/gen-doc-graphs.ts
@@ -101,7 +101,7 @@ const SERVICE_ROLES: ServiceRole[] = [
title: 'Durable session persistence seam',
mode: 'seam',
implementations: ['session-persistence-jsonl', 'session-persistence-sqlite'],
- consumers: ['agent-loop', 'acp', 'session-query'],
+ consumers: ['agent-loop', 'tool-bash', 'hooks-claude', 'hooks-codex', 'acp', 'session-query'],
note: 'Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time.',
},
{
@@ -170,6 +170,13 @@ const SERVICE_ROLES: ServiceRole[] = [
consumers: ['tool-bash', 'hooks-claude', 'hooks-codex'],
note: 'The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them.',
},
+ {
+ key: 'bashEnv',
+ pkg: 'tool-bash',
+ title: 'Managed bash environment registry',
+ mode: 'core',
+ note: 'Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace.',
+ },
{
key: 'sandbox',
pkg: 'sandbox',
diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json
index 7530d07cf7..78af0eabe8 100644
--- a/scripts/type-equiv.manifest.json
+++ b/scripts/type-equiv.manifest.json
@@ -47,6 +47,7 @@
{ "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionHeader", "source": "packages/core/session/src/types.ts" },
{ "doc": "docs/core-data-structures/persistence.md", "symbol": "CreateSessionOptions", "source": "packages/core/session/src/types.ts" },
+ { "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionLocation", "source": "packages/session-persistence/session-persistence/src/index.ts" },
{ "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSurface", "source": "packages/session-query/session-query/src/types.ts" },
{ "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionRecord", "source": "packages/session-query/session-query/src/types.ts" },
@@ -90,6 +91,8 @@
{ "doc": "docs/core-data-structures/approval.md", "symbol": "ApprovalPolicy", "source": "packages/ui/user-approval/src/index.ts" },
{ "doc": "docs/core-data-structures/approval.md", "symbol": "ApprovalRequest", "source": "packages/ui/user-approval/src/index.ts" },
+ { "doc": "docs/core-data-structures/bash.md", "symbol": "DshEnvironmentKey", "source": "packages/bash/bash/src/types.ts" },
+ { "doc": "docs/core-data-structures/bash.md", "symbol": "DshEnvironment", "source": "packages/bash/bash/src/types.ts" },
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecRequest", "source": "packages/bash/bash/src/types.ts" },
{ "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" },
diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts
index e5d1b140a8..ba3b2b2cc0 100644
--- a/scripts/verify-package-readme-model-experience.ts
+++ b/scripts/verify-package-readme-model-experience.ts
@@ -71,6 +71,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = {
'packages/examples/jsonrpc-demo': { kind: 'indirect', reason: 'Only the externally configured plugin tree contributes model context.' },
'packages/ui/permission': { kind: 'indirect', reason: 'The service writes mechanism events rendered by dsh-user-approval and dsh-tool-bash.' },
'packages/ui/user-interaction': { kind: 'indirect', reason: 'Model-facing consumers render provider answers and seam errors.' },
+ 'packages/util/home': { kind: 'indirect', reason: 'Only dsh-tool-bash exposes the resolved home to model commands.' },
'packages/util/timeout': { kind: 'indirect', reason: 'Only timeout consumers render timeout outcomes.' },
'packages/util/retention': { kind: 'indirect', reason: 'Only retention consumers render retained content and omission metadata.' },
'packages/web/web': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-web.' },
diff --git a/tsconfig.build.json b/tsconfig.build.json
index f8ad4f99f1..678c8cd818 100644
--- a/tsconfig.build.json
+++ b/tsconfig.build.json
@@ -11,6 +11,7 @@
{ "path": "./vendor/hmr" },
{ "path": "./vendor/logger-console" },
{ "path": "./packages/util/brand" },
+ { "path": "./packages/util/home" },
{ "path": "./packages/util/paths" },
{ "path": "./packages/util/timeout" },
{ "path": "./packages/util/retention" },
diff --git a/tsconfig.json b/tsconfig.json
index 7ab3c01e64..2f433786bb 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -22,6 +22,7 @@
{ "path": "./vendor/hmr" },
{ "path": "./vendor/logger-console" },
{ "path": "./packages/util/brand" },
+ { "path": "./packages/util/home" },
{ "path": "./packages/util/paths" },
{ "path": "./packages/util/timeout" },
{ "path": "./packages/util/retention" },