Merge branch 'codex/simp-hide-concrete-agent-loop' into codex/simp-hide-subagent-internals

This commit is contained in:
Tianyi Cui
2026-07-15 15:08:25 +08:00
19 changed files with 377 additions and 1539 deletions

View File

@@ -15,7 +15,7 @@ Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `n
### `agent/created` — emit
An agent's fully composed scoped world was published in the AgentRegistry. Its session is already live in the session store. Setup is composition-only by contract; the subsequent `agent/session-start` boundary is the first supported place to inject or queue startup work. A synchronous listener throw vetoes publication and rollback emits the matching disposal edges; returned-promise rejection is observed and logged but cannot retroactively veto this synchronous boundary. A synchronous listener that requests the advanced registry detach does not remove the entry immediately: removal and the paired `agent/disposed` edge wait until the creation dispatch unwinds, so no later creation listener observes a disposal that preceded its own creation callback.
A fully configured agent and live session were published. Setup is composition-only; `agent/session-start` is the first startup-driving seam. Synchronous listener failure vetoes publication, while returned-promise rejection is reported. Detach requested during dispatch waits until every creation listener has observed the stable entry.
```ts cordis-catalog
'agent/created'(this: Scoped<Agent>, agent: Agent): void
@@ -23,11 +23,11 @@ An agent's fully composed scoped world was published in the AgentRegistry. Its s
Types: [Agent](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:303`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:127`](../../packages/core/agent/src/types.ts)
### `agent/disposed` — emit
An agent was removed from the registry. The concrete AgentLoop lifecycle emits this only after its driver and any in-flight turn reach quiescence; a custom agent registered through the public registry owns its own driver contract, which the registry cannot infer. Ordered teardown may still be detaching the session and unwinding scoped registrations when this runs.
An agent left the registry; AgentLoop emits this after driver quiescence but before session detachment and scoped-registration unwind. Custom registry users own their driver-ordering contract.
```ts cordis-catalog
'agent/disposed'(this: Scoped<Agent>, agent: Agent): void
@@ -35,7 +35,7 @@ An agent was removed from the registry. The concrete AgentLoop lifecycle emits t
Types: [Agent](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:318`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:136`](../../packages/core/agent/src/types.ts)
### `agent/error` — emit
@@ -47,13 +47,11 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w
Types: [Agent](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:592`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:271`](../../packages/core/agent/src/types.ts)
### `agent/pre-step` — serial
Awaited pre-step surface-mutation checkpoint, fired once per step AFTER `turn/start` (and after the prior step closed) but BEFORE this step's `step/start` — so anything a listener appends lands OUTSIDE the step, between `turn/start`/`step/end` and the upcoming `step/start`. `step` is the number of the step about to start. The loop awaits `ctx.serial('agent/pre-step', …)` after assembling the system prompt, then opens the step and derives the request history ONCE from whatever the surface now holds. This is where compaction belongs: it mutates the session surface in place (shadowing an older range with a summary node) with its log-only `compact/*` records cleanly outside any step, and the single subsequent derive reflects the mutation — so there is no double-derive and no listener can see (or be expected to act on) an assembled `messages` array that does not exist yet.
Serial (awaited in registration order), not a waterfall: a listener mutates the surface as a side effect; there is nothing to transform, but the loop must wait for the mutation to complete before opening the step and deriving. Cordis `serial` bails early if a listener returns a bail value; this event is typed and documented as `void`, so listeners must not return a semantic veto value. `fullSystemPrompt` is the assembled prompt a listener needs to measure pressure (the system prompt counts toward the budget), and `sessionPrefix` is the instance's composed agent/session-prefix product for the same reason — every request carries it in front of the derived history, and it is composed BEFORE this seam fires precisely so a pressure gate counts the prefix the request will actually send (never a stale logged one). `signal` cancels any in-flight work a listener starts (e.g. a summarization model call). Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered through `agent.ctx` fires only for that agent's dispatches; a listener on a plain plugin context fires for every agent. The dispatch `this` is the scope carrier (`Scoped<Agent>`), built by the emitting side via `scopeTarget`/`agentEvents`.
Awaited serial checkpoint for session-surface mutation after prompt assembly and before `step/start`; appends land outside the pending step. The loop derives history once afterward, so compaction records and replacements are included without rewriting an assembled request. The prompt and prefix are the exact pressure inputs for that request, and `signal` cancels listener work. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
```ts cordis-catalog
'agent/pre-step'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal): Promise<void> | void
@@ -61,11 +59,11 @@ Serial (awaited in registration order), not a waterfall: a listener mutates the
Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:425`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:190`](../../packages/core/agent/src/types.ts)
### `agent/prompt-submit` — waterfall
Waterfall: decide what happens to ONE drained queued message before it becomes a `user/message` — allow (optionally rewriting the prompt bytes or attaching `additionalContext`) or block it. Fires inside the already-open turn, per drained message. Maps onto Claude Code's `UserPromptSubmit` hook. Call `next()` to delegate to the default (allow unchanged), or return a PromptDecision without calling `next()` to short-circuit.
Allow, rewrite, or block one drained prompt before it becomes a user message. Call `next()` for the unchanged default.
```ts cordis-catalog
'agent/prompt-submit'(this: Scoped<Agent>, agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise<PromptDecision>): Promise<PromptDecision>
@@ -73,11 +71,11 @@ Waterfall: decide what happens to ONE drained queued message before it becomes a
Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:443`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:200`](../../packages/core/agent/src/types.ts)
### `agent/queued` — emit
A message entered the agent's inbox (queued or steering). Content and the resolved source are the detached, deeply-frozen values retained by the inbox. `source` has defaults applied and is not the caller's raw options.
Detached, frozen content entered the agent's inbox. Source defaults have already been applied, so these are the exact values retained for the log.
```ts cordis-catalog
'agent/queued'(this: Scoped<Agent>, agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void
@@ -85,11 +83,11 @@ A message entered the agent's inbox (queued or steering). Content and the resolv
Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:347`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:155`](../../packages/core/agent/src/types.ts)
### `agent/request` — waterfall
Waterfall: shape the step's call configuration — model switching, sampling overrides — by returning a replacement LlmCallConfig (the frozen seed is the config the loop would otherwise use). Config is ALL a listener shapes here: every request is a pure function of the session log (the reconstructability RFC), so model-visible content flows through the log channels — `inject()`, steering, prompt-submit `additionalContext`, prompt sections via `system-prompt/assemble`, or the header-logged session prefix via agent/session-prefix — never through request mutation, and the loop records whatever config the request actually uses as a `request/header` event before dispatch. The step's messages are already snapshotted when this fires (the `step/start` boundary): an `inject()` from a listener here lands in the log but joins the NEXT request. For surface mutation that must precede the snapshot (compaction), use agent/pre-step. Call `next()` to delegate, or return an LlmCallConfig without it to short-circuit.
Replace the frozen call configuration. Model-visible content must use logged channels; this seam cannot mutate messages. Injection here joins the next request because the current step boundary is already fixed.
```ts cordis-catalog
'agent/request'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>
@@ -97,15 +95,11 @@ Waterfall: shape the step's call configuration — model switching, sampling ove
Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:472`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:212`](../../packages/core/agent/src/types.ts)
### `agent/session-prefix` — waterfall
Waterfall: compose the SESSION PREFIX — request-only messages placed in front of the ENTIRE derived history (directly after the provider's system slot) on every request this loop instance sends. Fired ONCE per loop instance, lazily before its first step's agent/pre-step seam — BEFORE the pre-step so a token-pressure gate (compaction) counts the prefix this instance will actually send, never a previous instance's logged one. The composed result is deep-frozen, recorded as `EpochHeader.messagePrefix` on the instance's anchoring `'initial'`/`'resume'` header snapshot, and reused verbatim for every subsequent request — never recomputed mid-session, so the provider prefix cache holds by construction (a process restart or `ctx.agents.resume()` is a new instance: it recomposes, and any drift lands attributably on the `'resume'` snapshot). Composition runs outside the step, before the boundary snapshot: a composing listener's session append joins the CURRENT request's derived history. A composition interrupted by a cancel/dispose landing inside the waterfall is discarded — never cached, logged, or sent — and the next turn recomposes under a live signal, so an abort-aware listener's degraded fallback cannot leak into later requests.
This is the home for session-stable openers the model must always see but that must NOT become durable history — a skills catalog, an AGENTS.md digest, a workspace baseline: `Session.deriveMessages()` never returns the prefix, and the header events are its only durable record, so the request stays reconstructable from the log. Content that CHANGES mid-session belongs in the append-only history channels instead — `agent.inject()`, a `tools/post-execute` decision's `additionalContext`, prompt-submit `additionalContext` — each a durable `context/message` paid once and prefix-cached thereafter.
The seed is a frozen empty list; a contributing listener returns a NEW array — never an in-place push. The canonical contribution is a PREPEND, `[mine, ...await next()]`: the waterfall unwinds innermost-first (the LAST-registered listener's `next()` resolves first), so prepending yields registration order on the wire, and every plugin using it composes deterministically. The append form `[...await next(), mine]` is legal but places a contribution AFTER every later-registered plugin's — reverse registration order when all contributors append. Call `next()` to delegate, or return a list without it to short-circuit. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered through `agent.ctx` fires only for that agent's dispatches; a listener on a plain plugin context fires for every agent. The dispatch `this` is the scope carrier (`Scoped<Agent>`), built by the emitting side via `scopeTarget`/`agentEvents`.
Compose request-only messages placed before derived history. The frozen result is computed once per loop instance, logged on its anchoring request header, and reused so the provider prefix remains stable. Interrupted composition is discarded. Composition precedes the first `agent/pre-step` and request boundary, so listener appends join the current request and pressure accounting sees the composed prefix. Changing context belongs in history; contributors should prepend to `await next()` to preserve registration order. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
```ts cordis-catalog
'agent/session-prefix'(this: Scoped<Agent>, agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise<Message[]>): Promise<Message[]>
@@ -113,11 +107,11 @@ The seed is a frozen empty list; a contributing listener returns a NEW array —
Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:524`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:227`](../../packages/core/agent/src/types.ts)
### `agent/session-start` — emit
The agent's session lifecycle began, fired once before its first turn. `source` says why (SessionStartSource: fresh startup, a resumed persisted session, …). A pure NOTIFICATION (emit, not waterfall): a listener cannot veto by returning a decision or throwing. A listener that wants to seed context does so via `agent.inject()` (a `context/message` the first request sees). A lifecycle owner can still dispose its structural ownership edge during this notification; publication rechecks liveness and then aborts before the driver starts.
The session lifecycle began, once before the first turn. Use `agent.inject()` to seed model-facing context. This is a notification, not a veto; disposal requested by a lifecycle owner is rechecked before the driver starts.
```ts cordis-catalog
'agent/session-start'(this: Scoped<Agent>, agent: Agent, source: SessionStartSource): void
@@ -125,11 +119,11 @@ The agent's session lifecycle began, fired once before its first turn. `source`
Types: [Agent](../core-data-structures/core.md) · [SessionStartSource](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:368`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:168`](../../packages/core/agent/src/types.ts)
### `agent/status` — emit
Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle off this transition, never off a status you just requested — `send()` does not flip status to `running` before it returns.
Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does not enter `running` synchronously; drive lifecycle from this event.
```ts cordis-catalog
'agent/status'(this: Scoped<Agent>, agent: Agent, status: AgentStatus): void
@@ -137,7 +131,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle
Types: [Agent](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:332`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:145`](../../packages/core/agent/src/types.ts)
### `agent/step-result` — waterfall
@@ -149,11 +143,11 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va
Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:539`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:238`](../../packages/core/agent/src/types.ts)
### `agent/turn-continuation` — waterfall
Waterfall: override the turn-continuation decision via a typed ContinuationDecision. The loop's `defaultDecision` is `continue` when the step had tool calls or steering was injected, else `stop`. Listeners force-continue (`/goal`, `/loop` — optionally attaching a `reason` recorded as next-step steering) or force-stop (budget guards). Call `next()` to delegate to the default, or return a decision to override.
Override whether the turn continues. The default continues after tool calls or steering and stops otherwise; a continue reason becomes steering.
```ts cordis-catalog
'agent/turn-continuation'(this: Scoped<Agent>, agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise<ContinuationDecision>): Promise<ContinuationDecision>
@@ -161,11 +155,11 @@ Waterfall: override the turn-continuation decision via a typed ContinuationDecis
Types: [Agent](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:557`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:248`](../../packages/core/agent/src/types.ts)
### `agent/turn-stop` — serial
Serial terminal-stop checkpoint after the ordinary `agent/turn-continuation` waterfall, any `continue.reason`, and the pending-steering continuation override have been folded. A listener returns `{ action: 'stop' }` to make this turn terminal, or `undefined` to abstain. Terminal stop is monotonic: listener order and steering cannot resume the turn, and pending steering is discarded rather than becoming another step or turn.
Monotonic terminal-stop checkpoint after continuation and steering are folded; a stop remains authoritative through turn close and flush: steering queued in that window is discarded, while ordinary sends survive.
```ts cordis-catalog
'agent/turn-stop'(this: Scoped<Agent>, agent: Agent, turn: number): ContinuationStop | undefined
@@ -173,7 +167,7 @@ Serial terminal-stop checkpoint after the ordinary `agent/turn-continuation` wat
Types: [Agent](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:575`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:258`](../../packages/core/agent/src/types.ts)
## `agent-loop/*`

View File

@@ -157,17 +157,8 @@ interface GenerateOptions {
stop?: string[]
signal?: AbortSignal
/**
* The id of the session this request belongs to — stamped by the agent loop
* from `agent.session.id`. Adapters ignore it; it lets an `llm/stream` listener
* route a call by WHICH session issued it (the replay adapter keys its per-call
* cursor by session, so a parent and its in-process subagent — each with its
* own session on one context — replay from their own recorded scripts).
*
* Typed as `Branded<'SessionId'>` rather than importing `SessionId` from
* `dsh-session`: that package imports `Message` from here, so importing its
* `SessionId` back would cycle. `SessionId` IS `Branded<'SessionId'>`, so a
* real session id assigns with no cast. (A future ids package could own the
* brand and dissolve this note.)
* Session identity stamped by the loop for listener routing. Adapters ignore
* it; replay uses it to keep concurrent parent and child cursors independent.
*/
sessionId?: Branded<'SessionId'>
}
@@ -354,7 +345,9 @@ interface Agent {
}
```
`AgentStatus` is `'idle' | 'running' | 'disposed'`. `Agent.id` and `Agent.session.id` are the same branded `SessionId`. `AgentOptions` (`model?`) is merge-extensible — plugins add creation options by declaration merging. Persona is not an agent option: the `dsh-system-prompt` config supplies the global default, and an agent-scoped `deployment:persona` section may shadow it. The `agent/*` event taxonomy (lifecycle emits incl. `agent/session-start`, serial `agent/pre-step`/`agent/turn-stop` checkpoints, and the `agent/prompt-submit`/`agent/request`/`agent/session-prefix`/`agent/step-result`/`agent/turn-continuation` waterfalls) is in [architecture.md § Event taxonomy](../architecture.md#event-taxonomy); turn/step boundaries are durable `session/event` records, not `agent/*` emits.
`AgentStatus` is `'idle' | 'running' | 'disposed'`. `Agent.id` and `Agent.session.id` are the same branded `SessionId`. `AgentOptions` is merge-extensible and currently includes `model?`. Persona belongs to `dsh-system-prompt`: an agent-scoped `deployment:persona` may shadow the global default.
The [event taxonomy](../architecture.md#event-taxonomy) owns the `agent/*` lifecycle, checkpoint, and waterfall contracts. Turn and step boundaries are durable session events rather than agent emits.
## Interception decisions
@@ -397,7 +390,7 @@ type ContinuationStop = Extract<ContinuationDecision, { action: 'stop' }>
type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact'
```
`agent/session-prefix` composes the session prefix — a plain `Message[]`, no dedicated payload type. Fired ONCE per loop instance, lazily on its first request: the composed list is deep-frozen, recorded as the header's `messagePrefix` ([the request envelope](#the-request-envelope-llmcallconfig-and-the-logged-header)), and placed in front of the ENTIRE derived history on every request the instance sends — the home for session-stable openers like a skills catalog or an AGENTS.md digest, never returned by `deriveMessages()`. Reuse is structural, so the prefix cannot drift mid-session (resume = a new instance = a recompose); content that changes mid-session goes through the append-only history channels instead (`agent.inject()`, `tools/post-execute` / prompt-submit `additionalContext`). Not a Decision union: the seam contributes content instead of vetoing, so the shape is the contribution itself.
`agent/session-prefix` composes a `Message[]` once per loop instance. The deep-frozen result is recorded in the request header and prepended to every derived history, making it the home for session-stable openers. A resumed instance recomposes; mid-session changes use append-only context channels. The waterfall returns content directly because it contributes rather than decides.
## `ToolDefinition`

View File

@@ -8,19 +8,19 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| Event | Mode | Declared in | Dispatchers | Listeners |
| --- | --- | --- | --- | --- |
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:352`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) |
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:303`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:318`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) |
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:592`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:425`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) |
| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:443`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:347`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:472`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:524`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) |
| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:368`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`invariants`](../packages/support/invariants), [`stdio-agent`](../packages/ui/stdio-agent) |
| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:332`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio-agent`](../packages/ui/stdio-agent) |
| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:539`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:557`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:575`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:127`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:136`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) |
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:271`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:190`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) |
| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:200`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:155`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:212`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:227`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) |
| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:168`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`invariants`](../packages/support/invariants), [`stdio-agent`](../packages/ui/stdio-agent) |
| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:145`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio-agent`](../packages/ui/stdio-agent) |
| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:238`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:248`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:258`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:31`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) |
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:59`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:68`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) |

View File

@@ -57,7 +57,7 @@ Raw stream chunk — token-level replay fidelity.
Types: [StreamChunk](core-data-structures/llm-streaming.md)
Source: [`packages/core/session/src/types.ts:287`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:208`](../packages/core/session/src/types.ts)
#### `assistant/message` — surface
@@ -69,7 +69,7 @@ Assembled assistant message for one step (derived history uses this). Carries th
Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-structures/llm-streaming.md)
Source: [`packages/core/session/src/types.ts:294`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:215`](../packages/core/session/src/types.ts)
### `bash/*`
@@ -129,7 +129,7 @@ In-session context injection (file-change notices, subdir AGENTS.md, skill conte
Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md)
Source: [`packages/core/session/src/types.ts:285`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:206`](../packages/core/session/src/types.ts)
### `hook/*`
@@ -169,7 +169,7 @@ Source: [`packages/ui/permission/src/index.ts:33`](../packages/ui/permission/src
#### `prompt/blocked` — log-only
A queued prompt an `agent/prompt-submit` listener VETOED — the durable record of a blocked prompt and why. Appended in place of the `user/message` the prompt would have become, so the block survives replay even in a MIXED batch where another queued prompt is allowed (there the turn does not end `rejected`, so the boundary reason alone would not preserve it). `content` is the original prompt the listener rejected; `reason` is the veto text (PromptDecision `block.reason`). NOT a SurfaceEventType: a blocked prompt produces no LLM message and never reaches `deriveMessages()`.
Durable record of a prompt veto and its reason. It is log-only: the blocked prompt never enters the model-visible surface, including in a mixed batch.
```ts persistence-catalog
'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string }
@@ -177,19 +177,19 @@ A queued prompt an `agent/prompt-submit` listener VETOED — the durable record
Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md)
Source: [`packages/core/session/src/types.ts:279`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:200`](../packages/core/session/src/types.ts)
### `request/*`
#### `request/header` — log-only
Full snapshot of the EpochHeader the NEXT request is built under, with the RequestHeaderReason it was recorded whole. Appended by the loop inside the step, before dispatch, on a loop instance's first request-building step (`'initial'`/`'resume'`) or when a later request's header changes (`'change'`); always records what the request actually used, post-`agent/request`. Reconstruction reads the latest snapshot. NOT a SurfaceEventType: it produces no LLM message — it is the request envelope, logged so every request is a pure function of the session log (the reconstructability RFC).
Full header for the next request, appended inside its step before dispatch. It is log-only; the latest snapshot reconstructs the request header.
```ts persistence-catalog
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
```
Source: [`packages/core/session/src/types.ts:338`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:240`](../packages/core/session/src/types.ts)
### `steering/*`
@@ -203,7 +203,7 @@ Steering content injected between steps of a running turn.
Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md)
Source: [`packages/core/session/src/types.ts:312`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:233`](../packages/core/session/src/types.ts)
### `step/*`
@@ -215,7 +215,7 @@ Closes step `step` of turn `turn`.
'step/end': { turn: number; step: number }
```
Source: [`packages/core/session/src/types.ts:266`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:193`](../packages/core/session/src/types.ts)
#### `step/start` — log-only
@@ -225,15 +225,13 @@ Opens step `step` of turn `turn` — one model call plus the tool executions it
'step/start': { turn: number; step: number }
```
Source: [`packages/core/session/src/types.ts:264`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:191`](../packages/core/session/src/types.ts)
### `todo/*`
#### `todo/write` — log-only
The agent's whole todo list, carried as a full snapshot and replaced wholesale on each write — the current list is the most recent `todo/write` (last-write-wins on replay, no fold). Appended by an owning agent via `session.append('todo/write', { todos })`.
NOT a SurfaceEventType: it produces no LLM message and never reaches `deriveMessages()`, so it carries no `surfaceOp` and stays off the surface — it is durable, replayable UI state, distinct from the conversation history. It is a `SessionEventMap` member riding the existing `session/event` emit, not a first-class Cordis `interface Events` notification, so it has no cordis-catalog row.
Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history.
```ts persistence-catalog
'todo/write': { todos: TodoItem[] }
@@ -241,7 +239,7 @@ NOT a SurfaceEventType: it produces no LLM message and never reaches `deriveMess
Types: [TodoItem](core-data-structures/session.md)
Source: [`packages/core/session/src/types.ts:326`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:235`](../packages/core/session/src/types.ts)
### `tool/*`
@@ -255,7 +253,7 @@ The model requested one tool invocation: `name` with the raw `arguments` JSON st
Types: [CallId](core-data-structures/core.md)
Source: [`packages/core/session/src/types.ts:300`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:221`](../packages/core/session/src/types.ts)
#### `tool/code-dispatch` — log-only
@@ -279,7 +277,7 @@ A completed tool call's model-facing result, plus an optional tool-private `meta
Types: [CallId](core-data-structures/core.md) · [ContentBlock](core-data-structures/core.md)
Source: [`packages/core/session/src/types.ts:310`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:231`](../packages/core/session/src/types.ts)
### `turn/*`
@@ -293,7 +291,7 @@ Closes turn `turn` with the TurnEndReason that ended it. The loop fires the awai
Types: [TurnEndReason](core-data-structures/session.md)
Source: [`packages/core/session/src/types.ts:262`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:189`](../packages/core/session/src/types.ts)
#### `turn/start` — log-only
@@ -305,7 +303,7 @@ Opens turn `turn`. `trigger` records what started it — a drained message batch
Types: [TurnTrigger](core-data-structures/session.md)
Source: [`packages/core/session/src/types.ts:256`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:183`](../packages/core/session/src/types.ts)
### `user/*`
@@ -319,4 +317,4 @@ A user-visible prompt (queued message drained at turn start).
Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md)
Source: [`packages/core/session/src/types.ts:268`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:195`](../packages/core/session/src/types.ts)

View File

@@ -4,7 +4,7 @@ Status: implemented
## Problem
Two gaps shared one root. First, provider KV caching (DeepSeek context caching) is prefix-based — a request pays full price only for the tokens after the longest stored prefix it matches — yet nothing in the request pipeline stated, checked, or measured prefix stability: every registered [`PromptSection`](../../../../packages/core/system-prompt/src/index.ts) happened to be static, the tool set happened not to change mid-session, no listener happened to rewrite requests. A single time-interpolating section would have silently multiplied context cost, and no test or metric would have moved. Second, and deeper: the session log — the system's single source of truth — could not actually answer *what the model saw*. It recorded every message but never the system prompt, the tool schemas, or even which model; the mutable `agent/request` waterfall handed listeners the whole `GenerateOptions` to rewrite per call; replay equivalence was therefore a property of the plugin population, not of the design.
The request pipeline did not guarantee prefix stability for provider caching, and the session log could not reconstruct what the model saw. It omitted model, system prompt, and tool schemas while allowing per-call request rewrites. Cache behavior and replay equivalence therefore depended on whichever plugins happened to be loaded.
The reference shape for the happy path is MiniCode's `LLMClient`: a stateful conversation client, appended to — never rebuilt — as the conversation advances, resetting only when the system prompt, tool set, or compaction genuinely changes what the model must see. The design question this RFC answers is how to get that discipline without giving up event-sourcing.

View File

@@ -34,7 +34,7 @@ An earlier draft put the full algorithm (the retention walk, token-summing, text
### Auto-compaction runs on `agent/pre-step`, a dedicated surface-mutation seam
Compaction is a **surface mutation**, not a request transform — and that distinction is the seam it belongs on. The loop's request lifecycle, per step, is: assemble the system prompt → open the step → derive the message history from the surface → run the `agent/request` waterfall → call the model. An earlier cut wedged compaction into the `agent/request` waterfall, which forced two problems: (1) the loop had already derived `messages` from the *stale* surface, so the listener had to mutate the surface and then *re-derive* and overwrite `request.messages` — a double-derive whose only purpose was to undo the premature first derive; and (2) `agent/request` also carries downstream-injected context a listener might have added to `request.messages`, which compaction cannot act on (it can only compact the surface), inviting the confusion of measuring tokens compaction can't shed.
Compaction mutates the session surface, so it runs before the step opens and before messages are derived. `agent/request` remains a call-config transform and never needs to rebuild history after a surface change.
The fix is a dedicated loop seam, **`agent/pre-step`** (`@mode serial`), fired by the loop *after* system assembly and *before* the step opens (`step/start`):
@@ -62,7 +62,7 @@ A runaway turn thus compacts exactly like any other history: its early *closed*
### Head-anchoring: one auto checkpoint, always at the head
`compactIfNeeded` always anchors the compacted range at the surface **head** (`nodes[0]`). After a first compaction lands a summary node at the head, the *second* compaction's range starts at that summary node and re-summarizes it together with the steps accumulated since — so the surface holds **at most one** auto-generated checkpoint, always at the head, re-consolidated each cycle (the backend's checkpoint-merge prompt makes this a cheap incremental merge — see below). This is *why* `CompactionResult.shadowedRange` is a **surface-position span, not a numeric seq interval**: after a replace lands a fresh high-seq summary node at an older range's position, `start` can be numerically **greater** than `end`. The range is resolved positionally (index into the ordered node list and slice), and `shadowedSeqs` is the authoritative set in surface order. (Manual `compactRegion` may target any aligned mid-range and so *can* leave several checkpoints; the checkpoint framing does not claim everything after it is recent.)
Auto-compaction always starts at the surface head, merging the prior checkpoint with newly compacted history so only one automatic checkpoint remains. `shadowedRange` is therefore positional rather than a numeric sequence interval: a newer summary sequence may occupy an older surface position. `shadowedSeqs` records the authoritative surface order. Manual mid-range compaction may leave multiple checkpoints.
### Approximate convergence invariant
@@ -85,7 +85,7 @@ compact/end → log-only. Releases the lock (carries `error` on a recoverab
### Checkpoint framing + incremental merge (backend-private)
The landed `user/message` is not the raw summary: the backend wraps it in a checkpoint preamble (so a resuming model reads it as established background, not a fresh request) and `<compacted-summary>…</compacted-summary>` tags. The tags make a prior checkpoint detectable on the next cycle, and the summarization prompt then instructs the model to *merge it in place* (preserve still-true facts, drop stale) rather than re-summarize verbatim — a cheap incremental merge that needs no extra log/event machinery. The raw, unframed summary stays on the `compact/summary` provenance event. This framing is entirely a **backend HOW decision** — the contract only promises "a single replace `user/message` carries the (possibly framed) summary; the raw summary lives on `compact/summary`." A template or remote backend may frame differently or not at all.
The basic backend wraps the summary as established checkpoint context and tags it for incremental merging on the next cycle. The raw summary remains on `compact/summary`. Framing is backend policy; the seam promises only that one replacement user message carries the possibly framed summary.
### Blocking via a log-recorded lock, plus a crash/recoverable failure taxonomy
@@ -121,7 +121,7 @@ Two failure paths, both documented:
## Testing
- **Unit** (`dsh-compact-basic`): the whole-unit retention walk, the convergence-invariant throw, both failure paths (`compact/end` with/without `error`), head-anchoring producing a non-monotonic `shadowedRange`, decline-on-open-tail, crash-orphan inertness, and the **runaway-turn regression** — a single oversized open turn compacts its early closed steps (proven to fail on the layer-2 protection it replaced). Driven through the real `dsh-invariants` plugin and the real Loader/inject path.
- **Loop** (`dsh-agent-loop`): `agent/pre-step` fires once per step, after `turn/start` and before `step/start`, awaited; a surface mutation in a `pre-step` listener lands outside the step and is reflected in the single derived request.
- **With-key e2e** (`examples/coding-agent`): a real model + real bash session with a lowered `contextWindow`/`retainTokens` triggers compaction mid-session; the test verifies the WORLD (a `compact/start…end` pair landed, the surface shrank, the agent still completed the task after compaction). This is compaction's first real-world exercise and the runaway-survival net.
- **Snapshot (deferred, named gap)**: a full-transcript snapshot of a runaway-turn compaction is NOT yet possible — `dsh-llm-replay` derives one model call per `(turn, step)` from `assistant/chunk` events, but the summarization call records no `assistant/chunk`s and carries no `sessionId` (it binds to the anonymous cursor and claims a non-existent extra script). Covering it needs net-new replay infrastructure (record/replay an interleaved summarization call) and is scheduled as a follow-up rather than discovered mid-build.
- **Unit:** Real Loader and invariant plugins cover whole-unit retention, convergence failure, both `compact/end` outcomes, head anchoring, open-tail refusal, inert crash orphans, and compacting closed steps inside one oversized open turn.
- **Loop:** Tests pin one awaited `agent/pre-step` per step between `turn/start` and `step/start`; a surface mutation there lands outside the step and appears in the single derived request.
- **With-key e2e:** A real model and bash session with lowered limits triggers compaction, records a complete `compact/start…end` pair, shrinks the surface, and finishes the task.
- **Snapshot gap:** Runaway-turn compaction cannot yet replay because the summarization call records no `assistant/chunk` events or `sessionId`; interleaved summarization-call replay remains follow-up work.

View File

@@ -4,7 +4,7 @@ Status: implemented
## Problem
The order of the tool list a model call carries — `request/header.tools` on the session log and `GenerateOptions.tools` on the wire — was an emergent artifact: the tool registry returns schemas in registration order, the system-prompt assembly concatenates providers in registration order, and the loop logged and dispatched the result verbatim. Registration order is plugin load order, and plugin load order is a race: the cordis loader imports every `cordis.yml` entry concurrently, so which tool plugin registers first depends on module-import completion timing. The plugin dependency relation cannot rescue this — it is a partial order under which independent tool plugins (e.g. `tool-subagent` vs `tool-todo`) are incomparable, so both interleavings are legal linearizations. This stopped being theoretical when a CI runner resolved the race differently from every recording machine: snapshot goldens pinned one permutation of `request/header.tools`, the `node 22.18` CI leg produced the other, and 5/5 snapshot tests failed on a diff that was pure array reordering. Tool order is part of the request bytes (prompt-cache stability, potentially model behavior) and, since the reconstructability contract, part of the durable session log — it must be a decision, not a residue.
Model-facing tool order followed plugin registration order, which depends on concurrent module loading for otherwise independent plugins. That race produced different request headers in CI and snapshot recordings. Because order affects request bytes, caching, and the durable header, it needs an explicit deterministic policy.
## Decision
@@ -17,7 +17,7 @@ The system-prompt assembly owns the canonical model-facing tool order, exactly w
- The list must contain the rest entry exactly once and no duplicate names.
- When `toolOrder` is unset, the canonical order is plain lexicographic name order (code-unit comparison, locale-independent), so determinism requires no configuration.
The policy is applied where the list is born: `assemble()`, before the `system-prompt/assemble` waterfall. The assembly canonicalizes the tools it collects from providers the same way it sorts sections by their `order` field — on the initial assembly, killing the registration-order entropy at its source. The waterfall therefore starts from one deterministic list; when a listener leaves that order intact, the loop's `EpochHeader`, the `request/header` event, the deep-frozen request, and the dev invariant's cross-check inherit it with no new loop change.
`assemble()` canonicalizes provider tools before the `system-prompt/assemble` waterfall, removing registration-order variance at its source. The waterfall starts from this deterministic list; unchanged order then flows into the request header, frozen request, and reconstruction checks without loop-specific ordering logic.
Scope is deliberately narrow: this fixes the REGISTRATION-ORDER race, not plugin behavior. A `system-prompt/assemble` listener may still add, remove, or rearrange tools — same as it may edit sections after their sort — and owns the determinism of what it emits; the waterfall contract already demands deterministic listeners (the reconstructability invariant would catch a listener that diverges between build and replay).
@@ -46,4 +46,4 @@ Config plumbing follows the `persona` precedent, and `toolOrder` sits beside it:
## Testing
Unit tests on `dsh-system-prompt` pin the ordering semantics (lexicographic default, listed/rest placement, unknown-name rejection at assembly, reserved tool-name rejection, stable handling of shared names, provider-order independence), the pre-waterfall contract (listeners observe the canonical list; a listener-appended tool is not re-sorted), and each invalid-list rejection at load. Loop-level tests assert the `request/header` fold carries the canonical order for scrambled registration orders (identical across permutations), that a configured `toolOrder` reaches both the logged header and the dispatched deep-frozen request, that the frozen loop-built envelope survives to the adapter, and that an unregistered `toolOrder` name fails the turn with a balanced `error` `turn/end`, an `agent/error`, no step, no logged header, and no dispatched request. Forwarding is asserted at every level that exposes the key (`dsh-agent-core`, `dsh-stdio-agent`, `dsh-acp-agent`). The snapshot tier replays all scenarios while only the pinned `text-turn` header carries the full canonical tool list; non-pinning fixtures continue to compare through `{{tools}}`.
System-prompt tests cover lexicographic default order, listed/rest placement, provider-order independence, shared names, invalid lists, unknown or reserved names, the canonical pre-waterfall list, and the rule that listener-added tools are not re-sorted. Loop tests pin identical logged and dispatched order across registration permutations, forwarding through agent-core and both apps, deep-frozen requests, and balanced turn failure with no step, header, or adapter call for an unknown configured name. Snapshot replay keeps the full canonical list only in the pinned `text-turn` header; other fixtures continue to use `{{tools}}`.

View File

@@ -38,30 +38,13 @@ The swap is invisible to every consumer of `ctx.bash`: the bash tools, hook comm
Misconfiguration fails loud: `mode` outside the closed vocabulary is rejected at plugin load, and a host with no usable backend throws the structured `SANDBOX_UNAVAILABLE` — at `confine()` before the command ever spawns — rather than degrading to unconfined execution. `runnerCommand` on `dsh-sandbox-local` is the operator's explicit assertion of a bwrap-compatible runner (chain and probes skipped); it doubles as the deterministic fake-runner seam for keyless tests.
What the model then experiences: denied file effects come back as result facts with a `[sandbox: file access denied under <mode> mode]` marker plus standing instructions not to retry around them; under a confining executor the schema offers `sandbox_permissions` + `justification` for the one-approval escalated retry (validated strictly wider than the session's effective mode at execution); the system prompt deliberately does NOT state the sandbox mode — the model learns the boundary from the marker (which names the mode) when it hits it, instead of preemptively refusing work a standing declaration discourages. What an ACP editor experiences: one `Permissions` config-option select per session (advertised when the `dsh-permission` preset layer is composed; each preset bundles a sandbox mode and an approval policy and writes through to both knob events — a knob state outside the table derives a switch-away-only `custom` current), switchable at runtime; a sandbox switch simply changes what subsequent commands may do, while an approval-policy switch to `'never'` is stated in the prompt and narrated.
The product path, concretely (the escalation arc is verbatim from the recorded `escalation-approved` scenario; the denial leg is pinned on the real-kernel e2e tier):
```
tool/result … [sandbox: file access denied under read-only mode] ← the write RAN; the kernel refused it
tool/call bash {"command": "printf 'escalated\n' > escalated.txt && cat escalated.txt",
"sandbox_permissions": "workspace-write",
"justification": "the user asked to write escalated.txt in the workspace"}
→ the editor is prompted on this very call (session/request_permission through the approval seam); Allow once
tool/result "escalated" — THIS call ran under workspace-write and its result facts say so; the session stays read-only
```
Reject instead and nothing executes: the result is the verbatim `the user rejected escalating this command to "workspace-write"`, and the teaching makes that final — no re-ask.
Denied file effects return a `[sandbox: file access denied under <mode> mode]` marker and instructions not to work around the denial. A confining executor adds paired `sandbox_permissions` and `justification` fields for one approved retry that must be strictly wider than the session's effective mode. A grant widens only that retry; rejection executes nothing, returns `the user rejected escalating this command to "<mode>"`, and permits no re-ask. The prompt does not announce sandbox mode, avoiding preemptive refusal. When `dsh-permission` is composed, ACP exposes one `Permissions` select whose presets write both knob events; unmatched knobs appear as switch-away-only `custom`. Only a switch to the deterministic `'never'` approval policy is stated in the prompt and narrated.
### Design detail
#### Grounding — verified against the code
#### Scope grounding
- Runtime OS subprocesses exist at exactly two sites: the `ctx.bash` seam's single spawn (`packages/bash/bash-local/src/run.ts`; hook commands flow through `ctx.bash`, so bash confinement covers them transitively) and `subagent-acp`'s child agents (`packages/subagent/subagent-acp/src/run.ts`) — the second consumer that makes a shared seam due rather than preemptive under the [capability seams RFC](../architecture/2026-06-13-capability-seams.md)'s "don't split preemptively" rule.
- Everything else executes inside the harness process (fs is in-process `node:fs`, web is in-process `fetch`, every `ToolDefinition.execute()` closes over `ctx`): an OS sandbox wraps `execve` and cannot wrap an in-process function call, so "sandbox any tool" is policy at each tool's seam, never a mechanical transport change.
- `tools/pre-execute` (`allow`/`deny`/`ask`) exists, with `ask` serviced by [the approval seam](2026-07-06-approval-seam.md); the fs intent gates are version guards with no mode input yet.
- `dsh-bash`'s request/spec split (`BashExecRequest``resolve()``BashExecSpec`) carries per-call fields the way escalation needs — `owner` is the template: request-optional, spec required-but-nullable, carried verbatim — and the result types already speak `SandboxMode`, so a per-call policy field adds no dependency edge.
- The pinned-header snapshot design means a schema/description change churns at most one pinning fixture per suite, and the escalation fields are advertised only under a sandboxing executor — so they live in exactly one pinned header, the acp example suite's `permission-switching` fixture.
OS subprocess confinement applies to the bash executor, including hook commands, and later to ACP subagent children. Filesystem, web, and other tools execute in-process and require policy at their own seams; an argv wrapper cannot confine a function closing over `ctx`. The existing bash request/spec split carries per-call overrides, while `tools/pre-execute` and the approval seam own the human decision.
#### The seam: `ctx.sandbox`
@@ -75,33 +58,33 @@ Left open, for the phase that needs them: whether network restriction arrives as
#### Local backends and the shipped launcher
`dsh-sandbox-local` selects BY PLATFORM, once per lifetime, and caches the verdict: each platform names its runner chain, a chain of one is selected directly — probing arbitrates between candidates, and a sole candidate leaves nothing to arbitrate — and a chain of several is probed FUNCTIONALLY in preference order (build and enforce a real profile, never `--version` — a present-but-unusable `bwrap` must fail its probe). Linux: `bwrap` first (its mount profile is closest to the mode vocabulary: whole tree read-only, fresh `/dev`+`/proc`, `workspace-write` adds an ephemeral `/tmp` and rebinds the workspace root; deliberately no `--unshare-pid` and no network claim), else the npm-distributed `landlock-run` Landlock launcher. darwin: `sandbox-exec` speaking a Seatbelt (SBPL) profile — allow-default with `(deny file-write*)` plus write allow-lists, every granted root canonicalized because Seatbelt matches resolved paths (`/tmp` IS `/private/tmp`) — unprobed, the sole candidate. A platform with no chain fails closed at `confine()`; an unprobed runner that turns out unusable fails closed at EXECUTION instead — it refuses to run the command, and every wrap carries `runnerFailureSignatures` (the runner's own error prefix, which also matches the shell's runner-not-found message) so the consumer classifies that as a SANDBOX failure, never a task failure: on either path the command neither runs unconfined nor slips through as a plain failure. A non-empty `runnerCommand` config is the operator's assertion of a runner that fully enforces the bwrap-shaped profile — chain and probes skipped; it doubles as the deterministic fake-runner seam for keyless tests. It is not exempt from fail-closed execution: its wrap carries argv0-scoped outer-shell failure shapes (`exec: <argv0>: not found`, `<argv0>: No such file or directory`, `<argv0>: Permission denied`) as its runner-failure dialect, so a missing or unexecutable configured runner classifies as a sandbox failure like every other rung — never as a failing command, and never as a denial.
`dsh-sandbox-local` selects one platform runner per provider lifetime and caches the verdict. Linux functionally probes `bwrap` then Landlock; macOS uses Seatbelt. Unsupported platforms and unusable runners fail closed. Each wrap carries backend-specific denial and runner-failure signatures so `dsh-bash-sandbox` can distinguish a denied file effect from a broken sandbox. `runnerCommand` skips selection as an operator assertion of a bwrap-shaped runner, but missing or unexecutable commands still classify as sandbox failure and never run the payload unconfined.
The launcher is a ~300-line C program (plain C11 over the raw Landlock UAPI — no libraries beyond a statically linked musl, so the audit surface is that one file plus the kernel's stable syscall contract): `--ro <path>` / `--rw <path>` grants, `--`, the wrapped argv; it installs the ruleset on itself and `exec`s (rulesets are inherited across `execve`, and it sets `no_new_privs` before restricting); `--probe` enforces a maximal ruleset in a short-lived child and exits 0 only when the kernel actually enforces; launcher failures exit 125 without exec'ing.
The launcher lives in its own repository and reaches the harness as the npm package family [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) (the per-platform-package pattern of `node-addon-require-builtin` and esbuild): an entry package — `dsh-sandbox-local`'s one runtime dependency — plus per-platform binary packages selected at install time by npm's `os`/`cpu` fields. The entry package owns the launcher's CLI contract end to end (`launcherPath()` resolution with a never-existing fallback, the functional `probe()`, `grantArgs()` flag spelling), versioned together with the binary so probe-report parsing can never drift against it; the harness keeps only the policy side, `landlockProfileArgs()` mapping the mode vocabulary to grants. Native-only per-architecture builds, pack gates (binary presence, executability, ELF architecture), and the byte-pinned publish rehearsal are that repository's release pipeline; this repo's Landlock CI legs install the published family from the registry — the true consumer path — and prove real-kernel confinement through it.
The Landlock launcher ships through [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run), with platform binaries selected by npm. That package owns path resolution, probing, and CLI flags; the harness maps sandbox modes to grants. Versioning the entry point with its binaries keeps probe parsing and launch syntax aligned.
FIXME: Revisit the separate-repository boundary and try to maintain the launcher source and its platform package family inside this monorepo, so the native release surface and harness contract evolve together.
Profile parity is honest rather than identical: under Landlock, `read-only` grants `--ro /` plus `--rw /dev/null` (the node, not `/dev` — the host's `/dev/shm` is a persistent shared tmpfs), and `workspace-write` grants the HOST `/tmp` where bwrap's is ephemeral; under Seatbelt, `read-only` likewise grants only the `/dev/null` literal, and `workspace-write` grants the host `/tmp` plus the per-user darwin temp dir (`os.tmpdir()` — the platform's real temp area for mkstemp-family tools; omitting it would deny what the mode promises). Every wrap carries the rung's denial dialect (`denialSignatures`: EROFS text under bwrap, EACCES under Landlock, EPERM under Seatbelt) so consumers match the active backend rather than a cross-runner union. Enforcement is honest per ABI level: an older kernel enforces the subset its ABI governs (path truncate is ungoverned before ABI v3), the probe's report line distinguishes the cases, and every confined result carries the structured `enforcement: 'full' | 'partial'` fact — refusing partial enforcement would deny the fallback to precisely the older-kernel hosts that need it. The bwrap and Seatbelt profiles govern every promised file effect by construction, so their passing probes always report `full`.
Backend profiles share the mode contract but differ in necessary host grants. Landlock and Seatbelt allow only `/dev/null` in read-only mode; workspace-write also permits their required host temp roots. Each wrap carries backend-specific denial signatures. Landlock reports partial enforcement on older ABIs that cannot govern every operation, while successful bwrap and Seatbelt profiles report full enforcement.
#### The bash consumer
`dsh-bash-sandbox` extends `LocalBashExecutor` (spawn mechanics, process-group kills, spill files, background tasks, credential scrub inherited verbatim) and hands `ctx.sandbox` the exact `['bash', '-c', command]` argv it is about to spawn. A sandbox denial is a RESULT FACT, not an error: the command RAN and the kernel refused a file operation, so `result.sandbox.denied` is orthogonal to `exitCode`/`signal`. Classification is conservative text inference over the collected stderr tail against the WRAP's own dialect, so a backend is never credited with a denial text its kernel does not speak (bare EPERM under a Linux runner names non-file boundaries the mode vocabulary does not govern); the known residual false positive is non-sandbox text in the active dialect (an ssh auth failure under Landlock, a refused `kill` under Seatbelt), and a structured runner signal wins once one exists. A RUNNER failure is the opposite of a denial and outranks it in classification (a runner's error text can itself contain denial words): the wrap's `runnerFailureSignatures` matching a failed run means the sandbox broke and the command NEVER RAN — the foreground path re-throws it as the structured `SANDBOX_UNAVAILABLE` error (the late twin of the confine-time throw, carrying the runner's first stderr line), a settled background task stamps `sandbox.runnerFailed` and `bash_output` renders its own marker — so a broken sandbox can never read as a failing command.
`dsh-bash-sandbox` reuses local process execution and asks `ctx.sandbox` to wrap the exact bash argv. A kernel denial is a result fact independent of exit status and is inferred only from the selected wrap's stderr dialect. Runner failure outranks denial because it means the command never ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while settled background tasks set `sandbox.runnerFailed` for `bash_output`. This keeps broken confinement distinct from both task failure and an enforced denial.
The model's view is result facts only: the static tool description explains the denial marker (`[sandbox: file access denied under <mode> mode]`), encourages attempting commands that may be denied, and forbids retrying around a denial; when the escalation fields are advertised, a denied result additionally carries the escalation hint itself, so the sanctioned same-turn retry is prompted at the decision point rather than depending on the model recalling the description (§ Escalation). No prompt section states the sandbox mode (§ Per-session modes).
#### Escalation: one approved wider retry after a denial
The seam level is mechanism only. `BashExecRequest` carries `sandboxMode?: SandboxMode`, an explicit per-call policy input; `BashExecSpec` carries it required-but-nullable (the `owner` pattern: a forgotten field is a visible `undefined`, and `resolve()` is the one explicit defaulting step); `BashExecutor` exposes the capability fact `get sandboxMode(): SandboxMode | undefined``undefined` in the base class, the configured mode in `SandboxBashExecutor` — so the tool layer can advertise only what the mounted executor honors: composition truth, not configuration. The seam honors ANY explicit mode, including a narrower one; the wider-only ladder is escalation policy and lives in the tool. A non-sandboxing executor (`dsh-bash-local`) carries the field verbatim and confines nothing — the field reaching it means the caller bypassed the tool's gate, and its honest behavior stays unconfined execution, not a guess at enforcement it does not have.
`BashExecRequest.sandboxMode` is an optional per-call input; resolved specs make the field explicit. `BashExecutor.sandboxMode` advertises whether the mounted executor can honor it, so only a confining composition exposes escalation. The seam accepts any explicit mode; the tool owns the wider-only escalation rule. Non-sandboxing executors remain honestly unconfined.
`SandboxBashExecutor.resolve()` stamps the effective mode — escalation grant > session override > configured default — so `run()`/`start()` read the spec, never the config. The `danger-full-access` branch, the confine call, and the result facts all key off the spec's mode, and the per-task facts map carries each task's mode alongside its wrap facts (`notifyTaskDone()` stamps from the map entry): one escalated call — foreground or background — reports the mode it ACTUALLY ran under while every neighbor keeps its own.
The tool gate advertises two extra parameters exactly when `ctx.bash.sandboxMode` reports a confining mode at registration: `sandbox_permissions`, an enum of the closed escalation-target vocabulary — `workspace-write`/`danger-full-access`, every mode a session could ever escalate TO — and `justification`, required together with it. The enum is deliberately NOT cut down to the modes wider than the executor's DEFAULT: schemas are registry-global while the effective mode is per-session and switchable, so a default-relative ladder strands a session overridden NARROWER than the default (with a `danger-full-access` default and a `read-only` override it would advertise nothing at all — confined, but with no lever). Strict widening is instead enforced at EXECUTION against the call's effective mode (session override ?? executor default): a request that is not strictly wider fails closed with its own text and prompts no one. An escalating call resolves approval BEFORE anything executes — no `ctx.approval` composed, or no agent on the execution, fails closed with its own text; otherwise `ctx.approval.request({ agent, toolName: 'bash', callId, reason, signal })` with the audit-self-contained reason `escalate sandbox to ${mode}: ${justification}`, while the UI attaches the prompt to the already-streamed call (the command is visible there; the approval RFC's no-arguments rule holds). The four outcomes map to distinct results: `allowed-once` stamps `sandboxMode` onto the bash request and proceeds; `rejected`, `cancelled`, and `unavailable` each produce their own error text, so the model can tell a human "no" from a dismissed prompt from a missing channel. The grant is consumed by the very call that asked; nothing is stored.
When a confining executor is mounted, `bash` advertises paired `sandbox_permissions` and `justification` fields. The schema exposes the full closed escalation vocabulary because effective mode is per-session; execution rejects any target that is not strictly wider than that call's effective mode. Approval resolves before execution. `allowed-once` stamps the granted mode onto only that request, while `rejected`, `cancelled`, `unavailable`, a missing approval service, or a missing agent all fail closed with distinct results. No grant is persisted.
The tool description teaches — and a denied result itself prompts — the SAME-TURN flow when the fields exist: on a denial a wider mode would cure, escalate immediately in that turn by retrying the exact command once with `sandbox_permissions` (the narrowest mode that suffices) + `justification`, without detouring through chat to ask first — the approval prompt raised by the retry IS how the user consents. Never speculatively: an escalation is grounded in a real denial — normally the one the command just hit, up front only when the session already denied the same access and a prompt stating approvals are disabled turns the exception off entirely; a rejected escalation is final for that command. Denial-grounding is deliberately model discipline plus human judgment, not harness bookkeeping — the human sees the exact command and justification on the prompt (see Alternatives for why hard-matching is rejected). No new session events anywhere: the attempt is an ordinary `tool/call` whose logged arguments carry the two fields, the decision is the approval seam's `approval/asked`/`approval/decided` pair, the outcome is an ordinary `tool/result` whose sandbox facts name the mode it ran under. The asker lives in `dsh-tool-bash`, NOT the executor: a transport seam has no `agent`, no `callId`, and no business asking humans questions.
Escalation is a same-turn retry of the denied command with the narrowest sufficient `sandbox_permissions` and a `justification`; the approval prompt is the consent step. It must be grounded in an actual denial, except when the session already observed the same denied access, and a disabled or rejected approval ends that command. The retry, approval decision, and result use existing tool and approval events. `dsh-tool-bash` owns the ask because the executor seam has neither the agent nor call id required for user interaction.
Left open, recorded for the phase that picks them up: what a grant's scope identity is beyond the sandbox mode — the exact call, a path, a command prefix, the session, a time window — the question `allow_always` grant storage must answer before that option can be advertised; how cancellation behaves while an approval prompt is pending; and how escalation is defined for `run_in_background` denials that arrive via `bash_output`.
Left open, recorded for the phase that picks them up: what a grant's scope identity is beyond the sandbox mode — the exact call, a path, a command prefix, the session, a time window — the question `allow_always` grant storage must answer before that option can be advertised; and how escalation is defined for `run_in_background` denials that arrive via `bash_output`.
#### Per-session modes: the session log as the store
@@ -198,15 +181,13 @@ Costs and accepted limits:
- **The model may over-ask.** Escalating without denial grounding, or picking `danger-full-access` where `workspace-write` suffices: the description steers and the enum forces the ladder, but the human prompt is the actual gate; the `approval/asked` reasons make over-asking auditable, and a `prepend` policy answerer can auto-reject patterns a deployment never wants.
- **The advertised target set is static while the effective mode is per-session** (schemas are registry-global) — a session already at the widest mode is still offered the fields. Harmless by construction: the strict-wider check at execution, not the enum, is the safety boundary — a non-widening request fails with its own text and never prompts anyone.
- **A granted escalation is not a working sandbox.** An unavailable backend still fails closed even for a granted escalation to a confining mode — at `confine()` when the platform has no chain or every probe fails, at execution when an unprobed sole runner refuses (classified as a sandbox failure, not a command failure) — while a granted `danger-full-access` run never touches the provider at all: there the grant, not the probe, is the authority.
- **An idle switch lives in bridge memory until the next turn anchors it.** A crash in that window reverts it (reported honestly on `session/load`), and a session that never runs another turn never persists it — accepted, with the loop-owned idle commit turn named as future work if durability becomes a requirement.
- **An idle switch lives in bridge memory until the next prompt submission anchors it.** A crash in that window reverts it (reported on `session/load`), and a session that never submits another prompt never persists it — accepted, with a loop-owned idle commit turn left as future work if durability becomes required.
- **The approval narrator's restart baseline parses prompt prose.** The closed candidate sentence is owned by the writing module itself, so a wording change is a coordinated writer+parser edit in one file; a session whose headers predate the section silently adopts the current policy without a notice.
- **The approval section is still a dynamic prompt surface** (a `'never'` switch breaks provider prompt-prefix caching for that session). Accepted: policy switches are rare, and a model acting on a stale `'never'` is worse. The sandbox knob no longer touches the prompt at all.
- **The model may hold a stale belief about the sandbox mode** (nothing announces a switch). Accepted deliberately: the next attempt's marker or success corrects it, and the observed failure mode of announcing — preemptive refusal — is worse than one wasted retry.
## FAQ
Behavioral and usage questions only — every "why not X?" design question lives in [Alternatives considered](#alternatives-considered), whose job is exactly that.
- **A command came back with `[sandbox: file access denied under read-only mode]` — did it fail?** It RAN, and the kernel refused a file effect: the denial is a result fact orthogonal to exit code. The teaching forbids retrying around it; the one sanctioned move is the same command retried once with an escalation request.
- **How is a BROKEN sandbox told apart from a failing command?** Runner failure outranks denial in classification: a failed run matching the wrap's `runnerFailureSignatures` means the command NEVER ran — foreground re-throws the structured `SANDBOX_UNAVAILABLE` with the runner's stderr line, a background task stamps `sandbox.runnerFailed` and renders its own marker. A broken sandbox can never read as a failing command, and the command never runs unconfined.
- **What happens on a platform with no backend — Windows today?** `confine()` throws the fail-closed `SANDBOX_UNAVAILABLE` and the command never spawns; `win32` is a reserved EMPTY chain, pinned by test to fail closed identically until a Windows runner fills it (§ Deferred phases).
@@ -214,7 +195,7 @@ Behavioral and usage questions only — every "why not X?" design question lives
- **Does the sandbox restrict network or process visibility?** No — `SandboxMode` claims FILE effects only; the bwrap profile deliberately does not unshare pid, and no backend claims network. Whether network restriction becomes its own knob is left open in § The seam.
- **Which tools actually run confined?** OS subprocesses through `ctx.bash` — the bash tools, and hook commands transitively. fs/web/todo execute in-process, where an `execve` wrapper is mechanically meaningless; their `read-only` semantics arrive with the cross-family deferred phase, and until then the contract says bash-only honestly.
- **Does a granted escalation persist, or cover background tasks?** Neither: the grant is consumed by the very call that asked (foreground or background), that one call reports the mode it actually ran under, and every neighbor keeps its own. How escalation should be DEFINED for a background denial that only surfaces later via `bash_output` is left open in § Escalation.
- **When does an editor's mode switch take effect?** Mid-turn: appended immediately, honored by the very next call's stamp. Idle: held on the bridge's session record, anchored at the next turn's `agent/prompt-submit`, with N flips coalescing to at most one event (none if net-zero); a crash before anchoring reverts it and `session/load` reports the truth. The model is not told — its next command simply behaves under the new mode.
- **When does an editor's mode switch take effect?** Mid-turn: appended immediately, honored by the very next call's stamp. Idle: held on the bridge's session record, anchored at the next `agent/prompt-submit` inside its open turn, with N flips coalescing to at most one event (none if net-zero); a crash before anchoring reverts it and `session/load` reports the truth. The model is not told — its next command simply behaves under the new mode.
- **What survives a restart — and what if the operator changed the config default while the process was down?** Overrides replay from the session log (`effective = fold ?? config`), so a resumed session keeps its modes with zero catch-up machinery; a default that drifted offline changes behavior the same way a switch does (the approval policy, being stated, is additionally narrated with operator/config attribution).
- **What does `enforcement: 'partial'` on a result mean?** The selected backend enforces the subset its kernel ABI governs — e.g. Landlock before ABI v3 does not govern path truncate — and says so structurally instead of refusing the host; the probe's report line distinguishes the cases. The bwrap and Seatbelt profiles govern every promised file effect by construction, so they always report `full`.