Просмотр исходного кода

Merge branch 'worktree-hooks-d-subagent' into worktree-hooks-e-protocol

Tianyi Cui 2 месяцев назад
Родитель
Сommit
d72ceffd04

+ 13 - 13
docs/cordis-catalog/events-and-services.md

@@ -25,7 +25,7 @@ An agent was registered in the AgentRegistry and is ready to receive messages.
 
 
 Types: [Agent](../core-data-structures/core.md)
 Types: [Agent](../core-data-structures/core.md)
 
 
-Source: [`packages/core/agent/src/types.ts:229`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:233`](../../packages/core/agent/src/types.ts)
 
 
 #### `agent/disposed` — emit
 #### `agent/disposed` — emit
 
 
@@ -37,7 +37,7 @@ An agent was disposed and removed from the registry; its fiber and any in-flight
 
 
 Types: [Agent](../core-data-structures/core.md)
 Types: [Agent](../core-data-structures/core.md)
 
 
-Source: [`packages/core/agent/src/types.ts:235`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:239`](../../packages/core/agent/src/types.ts)
 
 
 #### `agent/error` — emit
 #### `agent/error` — emit
 
 
@@ -49,7 +49,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w
 
 
 Types: [Agent](../core-data-structures/core.md)
 Types: [Agent](../core-data-structures/core.md)
 
 
-Source: [`packages/core/agent/src/types.ts:354`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:358`](../../packages/core/agent/src/types.ts)
 
 
 #### `agent/pre-step` — serial
 #### `agent/pre-step` — serial
 
 
@@ -63,7 +63,7 @@ Serial (awaited in registration order), not a waterfall: a listener mutates the
 
 
 Types: [Agent](../core-data-structures/core.md)
 Types: [Agent](../core-data-structures/core.md)
 
 
-Source: [`packages/core/agent/src/types.ts:301`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:305`](../../packages/core/agent/src/types.ts)
 
 
 #### `agent/prompt-submit` — waterfall
 #### `agent/prompt-submit` — waterfall
 
 
@@ -75,7 +75,7 @@ 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)
 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:311`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:315`](../../packages/core/agent/src/types.ts)
 
 
 #### `agent/queued` — emit
 #### `agent/queued` — emit
 
 
@@ -87,7 +87,7 @@ A message entered the agent's inbox (queued or steering). `source` is the resolv
 
 
 Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md)
 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:248`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:252`](../../packages/core/agent/src/types.ts)
 
 
 #### `agent/request` — waterfall
 #### `agent/request` — waterfall
 
 
@@ -99,7 +99,7 @@ Waterfall: mutate the fully-assembled GenerateOptions before the model call (hoo
 
 
 Types: [Agent](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md)
 Types: [Agent](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md)
 
 
-Source: [`packages/core/agent/src/types.ts:320`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:324`](../../packages/core/agent/src/types.ts)
 
 
 #### `agent/session-start` — emit
 #### `agent/session-start` — emit
 
 
@@ -111,7 +111,7 @@ The agent's session lifecycle began, fired once before its first turn. `source`
 
 
 Types: [Agent](../core-data-structures/core.md)
 Types: [Agent](../core-data-structures/core.md)
 
 
-Source: [`packages/core/agent/src/types.ts:261`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:265`](../../packages/core/agent/src/types.ts)
 
 
 #### `agent/status` — emit
 #### `agent/status` — emit
 
 
@@ -123,7 +123,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle
 
 
 Types: [Agent](../core-data-structures/core.md)
 Types: [Agent](../core-data-structures/core.md)
 
 
-Source: [`packages/core/agent/src/types.ts:242`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:246`](../../packages/core/agent/src/types.ts)
 
 
 #### `agent/steering` — emit
 #### `agent/steering` — emit
 
 
@@ -135,7 +135,7 @@ Steering content was injected into a running turn.
 
 
 Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md)
 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:348`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:352`](../../packages/core/agent/src/types.ts)
 
 
 #### `agent/step-result` — waterfall
 #### `agent/step-result` — waterfall
 
 
@@ -147,7 +147,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va
 
 
 Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md)
 Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md)
 
 
-Source: [`packages/core/agent/src/types.ts:326`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:330`](../../packages/core/agent/src/types.ts)
 
 
 #### `agent/stream-chunk` — emit
 #### `agent/stream-chunk` — emit
 
 
@@ -159,7 +159,7 @@ A raw StreamChunk arrived from the model (token-level UI/log feed).
 
 
 Types: [Agent](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md)
 Types: [Agent](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md)
 
 
-Source: [`packages/core/agent/src/types.ts:343`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:347`](../../packages/core/agent/src/types.ts)
 
 
 #### `agent/turn-continuation` — waterfall
 #### `agent/turn-continuation` — waterfall
 
 
@@ -171,7 +171,7 @@ Waterfall: override the turn-continuation decision via a typed ContinuationDecis
 
 
 Types: [Agent](../core-data-structures/core.md)
 Types: [Agent](../core-data-structures/core.md)
 
 
-Source: [`packages/core/agent/src/types.ts:336`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:340`](../../packages/core/agent/src/types.ts)
 
 
 ### `llm/*`
 ### `llm/*`
 
 

+ 1 - 1
docs/core-data-structures/core.md

@@ -214,7 +214,7 @@ type SessionEvent<T extends SessionEventType = SessionEventType> = {
 }[T]
 }[T]
 ```
 ```
 
 
-The twelve event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `context/message`, `assistant/chunk`, `assistant/message`, `tool/call`, `tool/result`, `steering/message`, `todo/write`), the `deriveMessages()` projection rules, the `TurnTrigger`/`TurnEndReason` reasons, and the turn-enclosure invariant are on **[session.md](session.md)**. How the log is made durable — the `SessionPersistence` seam, JSONL/SQLite backends, the `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on **[persistence.md](persistence.md)**.
+The thirteen event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `prompt/blocked`, `context/message`, `assistant/chunk`, `assistant/message`, `tool/call`, `tool/result`, `steering/message`, `todo/write`), the `deriveMessages()` projection rules, the `TurnTrigger`/`TurnEndReason` reasons, and the turn-enclosure invariant are on **[session.md](session.md)**. How the log is made durable — the `SessionPersistence` seam, JSONL/SQLite backends, the `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on **[persistence.md](persistence.md)**.
 
 
 ## The agent handle
 ## The agent handle
 
 

+ 11 - 0
docs/core-data-structures/session.md

@@ -16,6 +16,17 @@ interface SessionEventMap {
   'step/end': { turn: number; step: number }
   'step/end': { turn: number; step: number }
   /** A user-visible prompt (queued message drained at turn start). */
   /** A user-visible prompt (queued message drained at turn start). */
   'user/message': { content: ContentBlock[]; source: MessageSource }
   'user/message': { content: ContentBlock[]; source: MessageSource }
+  /**
+   * 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
+   * ({@link PromptDecision} `block.reason`). NOT a {@link SurfaceEventType}: a
+   * blocked prompt produces no LLM message and never reaches `deriveMessages()`.
+   */
+  'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string }
   /**
   /**
    * In-session context injection (file-change notices, subdir AGENTS.md,
    * In-session context injection (file-change notices, subdir AGENTS.md,
    * skill content, cron notifications, …). Rendered into the derived history
    * skill content, cron notifications, …). Rendered into the derived history

+ 1 - 1
docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md

@@ -22,7 +22,7 @@ Three deliberate choices:
 
 
 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`.
 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`.
 
 
-`dsh-bash-local` now ALWAYS spawns stdin as a `'pipe'` and closes it immediately — with the supplied bytes when a caller set `stdin`, empty otherwise. A closed empty pipe gives a reading child EOF exactly as the previous `'ignore'` (`/dev/null`) did, so the no-stdin path is behavior-equivalent; keeping the `stdio` tuple a literal `['pipe','pipe','pipe']` also preserves the typed `spawn` overload that guarantees non-null `stdout`/`stderr`. A child that exits without reading makes the stdin write fail EPIPE; that error is swallowed (the command's outcome rides on its exit code/output, not the write) so it never crashes the host or rejects `done`.
+`dsh-bash-local` spawns stdin as a `'pipe'` (writing the supplied bytes, then closing) ONLY when a caller set `stdin`; with none supplied it uses `'ignore'` — fd 0 → `/dev/null` — the exact pre-seam default. This distinction is observable and deliberate: a closed empty pipe and `/dev/null` are NOT the same file type (node's spawn pipe is an `AF_UNIX` socket, so `test -c /dev/stdin` holds for `/dev/null` but not for an empty pipe), so the no-stdin path — every model-driven call — must keep `/dev/null` rather than regress to an always-open pipe. Each branch's `stdio` tuple is a literal, which preserves the typed `spawn` overload that guarantees non-null `stdout`/`stderr`. When stdin IS written, a child that exits without reading makes the write fail EPIPE; that error is swallowed (the command's outcome rides on its exit code/output, not the write) so it never crashes the host or rejects `done`.
 
 
 ## Scope: configurable scrub pattern is NOT included
 ## Scope: configurable scrub pattern is NOT included
 
 

+ 2 - 2
docs/rfc/implemented/feature/2026-06-30-interception-seams.md

@@ -16,7 +16,7 @@ Add/​reshape the interception seams so every one returns a small, seam-specifi
 
 
 **New `agent/*` events** (`dsh-agent`):
 **New `agent/*` events** (`dsh-agent`):
 - `agent/session-start(agent, source)` — emit, once before turn 1, carrying a `SessionStartSource` (`startup` for a fresh/forked create, `resume` for a reloaded persisted session; `clear`/`compact` reserved). A pure notification — it CANNOT block startup (a deliberate gap: a bridge logs/injects, it does not gate startup). A listener seeds context via `agent.inject()`.
 - `agent/session-start(agent, source)` — emit, once before turn 1, carrying a `SessionStartSource` (`startup` for a fresh/forked create, `resume` for a reloaded persisted session; `clear`/`compact` reserved). A pure notification — it CANNOT block startup (a deliberate gap: a bridge logs/injects, it does not gate startup). A listener seeds context via `agent.inject()`.
-- `agent/prompt-submit(agent, content, source, next) → PromptDecision` — waterfall, fired per drained queued message inside the open turn, before the `user/message` append. `allow` (optionally rewriting the prompt `content` or attaching `additionalContext`) or `block`.
+- `agent/prompt-submit(agent, content, source, next) → PromptDecision` — waterfall, fired per drained queued message inside the open turn, before the `user/message` append. `allow` (optionally rewriting the prompt `content` or attaching `additionalContext`) or `block` (dropping the prompt; the loop appends a durable `prompt/blocked` in its place — see the dispatch note below).
 
 
 **Reshaped** `agent/turn-continuation` from `(…, defaultDecision: boolean) → boolean` to `(…, defaultDecision: ContinuationDecision) → ContinuationDecision`. A `{action:'continue', reason?}` may carry model-facing context recorded as next-step steering in the same turn — the typed twin of the existing `/goal` step-end-steer pattern.
 **Reshaped** `agent/turn-continuation` from `(…, defaultDecision: boolean) → boolean` to `(…, defaultDecision: ContinuationDecision) → ContinuationDecision`. A `{action:'continue', reason?}` may carry model-facing context recorded as next-step steering in the same turn — the typed twin of the existing `/goal` step-end-steer pattern.
 
 
@@ -26,7 +26,7 @@ Add/​reshape the interception seams so every one returns a small, seam-specifi
 
 
 ### Three load-bearing loop decisions
 ### Three load-bearing loop decisions
 
 
-1. **Always open the turn first; a fully-blocked batch is a zero-step `rejected` turn.** `prompt-submit` fires AFTER `turn/start`, per message. A batch whose every prompt is blocked does NOT skip the turn — it opens a zero-step turn that closes with `rejected`. This one move resolves three problems at once: (1) turn-enclosure holds (every event has an open turn to live in); (2) the durable `turn/end` is appended and the ACP bridge settles normally off it (mapping `rejected`→`cancelled`) instead of hanging; (3) the block reason is a durable in-turn fact. An `allow`'s `additionalContext` is `inject()`ed into this now-open turn.
+1. **Always open the turn first; a fully-blocked batch is a zero-step `rejected` turn; every veto is recorded as `prompt/blocked`.** `prompt-submit` fires AFTER `turn/start`, per message. A batch whose every prompt is blocked does NOT skip the turn — it opens a zero-step turn that closes with `rejected`. This one move resolves three problems at once: (1) turn-enclosure holds (every event has an open turn to live in); (2) the durable `turn/end` is appended and the ACP bridge settles normally off it (mapping `rejected`→`cancelled`) instead of hanging; (3) the block reason is a durable in-turn fact. Independently, each individual veto appends a `prompt/blocked` session event (the original `content`, `source`, and `reason`) in place of the `user/message` the prompt would have become — necessary because a MIXED batch (one prompt blocked, another allowed) does NOT end `rejected`, so the boundary reason alone would silently lose the blocked prompt on replay. An `allow`'s `additionalContext` is `inject()`ed into this now-open turn.
 
 
 2. **Post-tool `additionalContext` is buffered and appended AFTER all `tool/result`s.** `content`/`feedback` shape the result `execute()` returns, but `additionalContext` is a SEPARATE `context/message`, and a single step can carry multiple tool calls. Appending context right after each result would interleave `result(c1) → context → result(c2)` and break tool-call/result adjacency. So `execute()` surfaces `additionalContext` on its `ToolExecutionResult`, and the loop buffers every per-call context for the step and appends them as `context/message`(s) only after every `tool/result` is appended.
 2. **Post-tool `additionalContext` is buffered and appended AFTER all `tool/result`s.** `content`/`feedback` shape the result `execute()` returns, but `additionalContext` is a SEPARATE `context/message`, and a single step can carry multiple tool calls. Appending context right after each result would interleave `result(c1) → context → result(c2)` and break tool-call/result adjacency. So `execute()` surfaces `additionalContext` on its `ToolExecutionResult`, and the loop buffers every per-call context for the step and appends them as `context/message`(s) only after every `tool/result` is appended.
 
 

+ 1 - 1
packages/bash/bash-local/README.md

@@ -21,7 +21,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.
 - **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 a 3s grace (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.
 - **Process-group kills with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after a 3s grace (OpenCode's escalation; pipelines and subshells die with the parent). ESRCH is tolerated; daemons that re-parent away from the group can still survive — same caveat as the surveyed tools.
 - **Tail-keep truncation + spill files** — output beyond `maxOutputBytes` keeps the in-memory TAIL (errors/results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a temp file whose path is reported when available. If the final spill close reports a delayed writeback failure, the executor still returns the tail but withholds the path rather than advertising a possibly incomplete file.
 - **Tail-keep truncation + spill files** — output beyond `maxOutputBytes` keeps the in-memory TAIL (errors/results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a temp file whose path is reported when available. If the final spill close reports a delayed writeback failure, the executor still returns the tail but withholds the path rather than advertising a possibly incomplete file.
-- **Model-friendly env + credential scrub** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`), then `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results. This scrub is the security control that keeps the harness's *ambient* credentials out of a spawned command. A spec's `env` is merged LAST (after the scrub), so a caller's explicit entry — a value it already holds — wins even on a credential-shaped name. The spec's `stdin` is written to the child and closed; with none supplied, stdin is an immediately-closed empty pipe (EOF, as before). 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*`), then `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results. This scrub is the security control that keeps the harness's *ambient* credentials out of a spawned command. A spec's `env` is merged LAST (after the scrub), so a caller's explicit entry — a value it already holds — wins even on a credential-shaped name. The spec's `stdin`, when supplied, is written to the child and closed; with none supplied, fd 0 is `/dev/null` — the exact pre-seam default, so a command that probes stdin's file type is unaffected. Both `env`/`stdin` are set by in-process plugins (the hooks bridges); the model-facing tool doesn't expose them. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
 - **Background tasks** — `start()` returns immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), `readOutput()` is incremental with whole-stream byte offsets, and disposal kills everything. The spec's opaque `owner` token is stored on the tracked task and returned by `ownerOf(id)` — the executor never interprets it (the consumer's access policy does), and because it lives with the task here it survives a `tool-bash` HMR reload.
 - **Background tasks** — `start()` returns immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), `readOutput()` is incremental with whole-stream byte offsets, and disposal kills everything. The spec's opaque `owner` token is stored on the tracked task and returned by `ownerOf(id)` — the executor never interprets it (the consumer's access policy does), and because it lives with the task here it survives a `tool-bash` HMR reload.
 
 
 ## Sandboxing
 ## Sandboxing

+ 26 - 18
packages/bash/bash-local/src/run.ts

@@ -15,7 +15,8 @@
  * @module dsh-bash-local/run
  * @module dsh-bash-local/run
  */
  */
 
 
-import { spawn } from 'node:child_process'
+import { type ChildProcessByStdio, spawn } from 'node:child_process'
+import type { Readable, Writable } from 'node:stream'
 import { randomBytes } from 'node:crypto'
 import { randomBytes } from 'node:crypto'
 import { closeSync, mkdtempSync, openSync, writeSync } from 'node:fs'
 import { closeSync, mkdtempSync, openSync, writeSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { tmpdir } from 'node:os'
@@ -298,17 +299,20 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
     throw new Error(`aborted before spawn: ${String(spec.signal.reason ?? 'aborted')}`)
     throw new Error(`aborted before spawn: ${String(spec.signal.reason ?? 'aborted')}`)
   }
   }
 
 
-  // stdin is ALWAYS a pipe (kept literal so the typed spawn overload guarantees
-  // non-null stdout/stderr) and is closed immediately: with bytes when a caller
-  // supplied stdin, empty otherwise. A closed empty pipe gives a reading child
-  // EOF exactly as `/dev/null` would, so the no-stdin path (every model-driven
-  // call) is unchanged.
-  const child = spawn('bash', ['-c', spec.command], {
-    cwd: spec.cwd,
-    env: childEnv(spec.env),
-    stdio: ['pipe', 'pipe', 'pipe'],
-    detached: true,
-  })
+  // stdin is a pipe ONLY when the caller supplied bytes; with none it is `ignore`
+  // (fd 0 → /dev/null) — the exact pre-seam default. This matters: a spawn pipe
+  // and /dev/null are NOT observationally identical (node's pipe is an AF_UNIX
+  // socket, so a command that probes stdin's type — `test -c /dev/stdin`, `stat
+  // /proc/self/fd/0` — sees a char device vs a socket), so the no-stdin path
+  // (every model-driven call) must keep /dev/null rather than regress to a socket.
+  // Two LITERAL `stdio` tuples (not one variable tuple): only a literal lets the
+  // typed `spawn` overload infer non-null stdout/stderr, which the
+  // `ChildProcessByStdio` annotation captures (stdin `Writable | null`; stdout/
+  // stderr the non-null `Readable` the collectors attach to without a cast).
+  const env = childEnv(spec.env)
+  const child: ChildProcessByStdio<Writable | null, Readable, Readable> = 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 })
 
 
   const stdout = new OutputCollector(spec.maxOutputBytes, 'stdout', spillDir)
   const stdout = new OutputCollector(spec.maxOutputBytes, 'stdout', spillDir)
   const stderr = new OutputCollector(spec.maxOutputBytes, 'stderr', spillDir)
   const stderr = new OutputCollector(spec.maxOutputBytes, 'stderr', spillDir)
@@ -343,10 +347,12 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
   }
   }
   spec.signal?.addEventListener('abort', onAbort, { once: true })
   spec.signal?.addEventListener('abort', onAbort, { once: true })
 
 
-  // Write stdin and close it. This handler must exist: an unhandled 'error' on
-  // the stream would throw and crash the host. We swallow the error rather than
-  // reject `done`, and that is correct for ANY stdin-write error, not just the
-  // common one — the stdin write is BEST-EFFORT, while the command's authoritative
+  // Write stdin and close it, but ONLY when the caller supplied bytes — with no
+  // stdin, fd 0 is `ignore` (/dev/null) and `child.stdin` is null. The error
+  // handler must exist whenever we write: an unhandled 'error' on the stream
+  // would throw and crash the host. We swallow the error rather than reject
+  // `done`, and that is correct for ANY stdin-write error, not just the common
+  // one — the stdin write is BEST-EFFORT, while the command's authoritative
   // outcome is its exit code + captured output, which the `close` handler reports
   // outcome is its exit code + captured output, which the `close` handler reports
   // regardless of whether the write landed. The expected case is EPIPE (the child
   // regardless of whether the write landed. The expected case is EPIPE (the child
   // exited without reading, so closing our end of a still-full pipe fails); a rare
   // exited without reading, so closing our end of a still-full pipe fails); a rare
@@ -354,8 +360,10 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
   // surfaces that itself through its own exit/output (e.g. a hook that gets
   // surfaces that itself through its own exit/output (e.g. a hook that gets
   // truncated JSON errors out) — rejecting here would instead discard that real
   // truncated JSON errors out) — rejecting here would instead discard that real
   // output and turn it into an opaque infrastructure error, which is worse.
   // output and turn it into an opaque infrastructure error, which is worse.
-  child.stdin.on('error', () => { /* stdin write is best-effort; outcome rides on exit/output. */ })
-  child.stdin.end(spec.stdin ?? '')
+  if (child.stdin !== null) {
+    child.stdin.on('error', () => { /* stdin write is best-effort; outcome rides on exit/output. */ })
+    child.stdin.end(spec.stdin)
+  }
 
 
   const done = new Promise<SpawnOutcome>((resolve, reject) => {
   const done = new Promise<SpawnOutcome>((resolve, reject) => {
     child.on('error', (error) => {
     child.on('error', (error) => {

+ 15 - 2
packages/bash/bash-local/tests/run.spec.ts

@@ -166,13 +166,26 @@ describe('stdin and extra env (set by in-process plugins)', () => {
   })
   })
 
 
   it('a command that reads stdin sees EOF when none is supplied', async () => {
   it('a command that reads stdin sees EOF when none is supplied', async () => {
-    // No stdin → the always-piped-but-empty stdin closes immediately, so `cat`
-    // reads EOF and exits 0 with no output (it does NOT block).
+    // No stdin → fd 0 is /dev/null, so `cat` reads EOF and exits 0 with no
+    // output (it does NOT block).
     const result = await runBash(spec('cat')).done
     const result = await runBash(spec('cat')).done
     expect(result.exitCode).toBe(0)
     expect(result.exitCode).toBe(0)
     expect(result.stdout.text).toBe('')
     expect(result.stdout.text).toBe('')
   })
   })
 
 
+  it('gives fd 0 the exact pre-seam type: /dev/null when no stdin, a pipe when supplied', async () => {
+    // The no-stdin path must stay observationally identical to the pre-seam
+    // `ignore` default: a command that probes stdin's file type sees a char
+    // device (/dev/null). Regressing to an always-open pipe would make fd 0 a
+    // socket (node's spawn pipe is an AF_UNIX socket, not a FIFO), flipping
+    // `test -c /dev/stdin` for every model-driven call. When bytes ARE supplied,
+    // fd 0 is that pipe (a socket), as it must be to carry them.
+    const none = await runBash(spec('test -c /dev/stdin && echo char || echo other')).done
+    expect(none.stdout.text).toBe('char\n')
+    const piped = await runBash(spec('test -S /dev/stdin && echo socket || echo other', { stdin: 'x' })).done
+    expect(piped.stdout.text).toBe('socket\n')
+  })
+
   it('merges extra env entries onto the scrubbed environment', async () => {
   it('merges extra env entries onto the scrubbed environment', async () => {
     const result = await runBash(spec('echo "$DSH_EXTRA_ONE/$DSH_EXTRA_TWO"', {
     const result = await runBash(spec('echo "$DSH_EXTRA_ONE/$DSH_EXTRA_TWO"', {
       env: { DSH_EXTRA_ONE: 'alpha', DSH_EXTRA_TWO: 'beta' },
       env: { DSH_EXTRA_ONE: 'alpha', DSH_EXTRA_TWO: 'beta' },

+ 1 - 1
packages/core/agent-loop/README.md

@@ -51,7 +51,7 @@ forever:
   TURN (error-contained):
   TURN (error-contained):
     'turn/start'
     'turn/start'
     each queued: waterfall agent/prompt-submit → allow (→ session('user/message'),
     each queued: waterfall agent/prompt-submit → allow (→ session('user/message'),
-      inject additionalContext) | block (drop)
+      inject additionalContext) | block (→ session('prompt/blocked'), drop)
     if every prompt blocked: 'turn/end'(rejected), no step  ⟵ zero-step turn
     if every prompt blocked: 'turn/end'(rejected), no step  ⟵ zero-step turn
     STEP loop:
     STEP loop:
       drain steering
       drain steering

+ 8 - 0
packages/core/agent-loop/src/loop.ts

@@ -385,6 +385,14 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle,
       )
       )
       if (decision.kind === 'block') {
       if (decision.kind === 'block') {
         lastBlockReason = decision.reason
         lastBlockReason = decision.reason
+        // Record the veto durably: `PromptDecision.reason` is the durable record
+        // of why a prompt was blocked, but a fully-blocked batch's `rejected`
+        // turn/end only preserves the LAST reason, and a MIXED batch (this prompt
+        // blocked, another allowed) does not end `rejected` at all — so without
+        // this append a blocked prompt would vanish from the log whenever any
+        // sibling prompt is allowed. `prompt/blocked` sits in the open turn in
+        // place of the `user/message` this prompt would have become.
+        session.append('prompt/blocked', { content: message.content, source: message.source, reason: decision.reason })
         continue
         continue
       }
       }
       anyAllowed = true
       anyAllowed = true

+ 45 - 0
packages/core/agent-loop/tests/interception.spec.ts

@@ -176,12 +176,57 @@ describe('agent/prompt-submit', () => {
     expect(log.some(e => e.type === 'turn/end')).toBe(true)
     expect(log.some(e => e.type === 'turn/end')).toBe(true)
     expect(log.some(e => e.type === 'user/message')).toBe(false)
     expect(log.some(e => e.type === 'user/message')).toBe(false)
     expect(log.some(e => e.type === 'step/start')).toBe(false)
     expect(log.some(e => e.type === 'step/start')).toBe(false)
+    // the veto is recorded durably as a prompt/blocked in the open turn
+    const blocked = log.find(e => e.type === 'prompt/blocked')
+    expect(blocked?.type === 'prompt/blocked' && blocked.data).toMatchObject({
+      content: [{ type: 'text', text: 'do something' }],
+      reason: 'blocked by policy',
+    })
     // ended rejected with the block reason
     // ended rejected with the block reason
     expect(reasons).toEqual([{ kind: 'rejected', reason: 'blocked by policy' }])
     expect(reasons).toEqual([{ kind: 'rejected', reason: 'blocked by policy' }])
     const turnEnd = log.findLast(e => e.type === 'turn/end')
     const turnEnd = log.findLast(e => e.type === 'turn/end')
     expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'rejected', reason: 'blocked by policy' })
     expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'rejected', reason: 'blocked by policy' })
   })
   })
 
 
+  it('a mixed batch records a prompt/blocked for the vetoed prompt while the allowed one runs', async () => {
+    // Two prompts queued into ONE turn: block "secret", allow "safe". The turn is
+    // NOT rejected (a prompt was allowed), so without a durable prompt/blocked the
+    // vetoed prompt and its reason would vanish from the log entirely.
+    const adapter = new MockAdapter([textResponse('ran once')])
+    const ctx = await harness(adapter)
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
+
+    ctx.on('agent/prompt-submit', async (_agent, content, _source, next): Promise<PromptDecision> => {
+      const text = content.map(b => (b.type === 'text' ? b.text : '')).join('')
+      return text === 'secret' ? { kind: 'block', reason: 'policy: no secrets' } : next()
+    })
+
+    const reasons: TurnEndReason[] = []
+    ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
+
+    // both sends land before the loop drains → one batched turn
+    send(agent, 'secret')
+    send(agent, 'safe')
+    await waitForIdle(ctx, agent)
+
+    const log = events(agent)
+    // the allowed prompt became a user/message and drove exactly one model call
+    const userMsgs = log.filter(e => e.type === 'user/message')
+    expect(userMsgs).toHaveLength(1)
+    expect(userMsgs[0]?.type === 'user/message' && userMsgs[0].data.content).toEqual([{ type: 'text', text: 'safe' }])
+    expect(adapter.requests.length).toBeGreaterThanOrEqual(1)
+    // the blocked prompt is durably recorded, with its content + reason
+    const blocked = log.filter(e => e.type === 'prompt/blocked')
+    expect(blocked).toHaveLength(1)
+    expect(blocked[0]?.type === 'prompt/blocked' && blocked[0].data).toMatchObject({
+      content: [{ type: 'text', text: 'secret' }],
+      reason: 'policy: no secrets',
+    })
+    // the turn did NOT reject — a sibling was allowed — so the boundary reason
+    // alone would not have preserved the block
+    expect(reasons.some(r => r.kind === 'rejected')).toBe(false)
+  })
+
   it('a throwing prompt-submit listener ends the turn balanced (error), loop survives', async () => {
   it('a throwing prompt-submit listener ends the turn balanced (error), loop survives', async () => {
     const adapter = new MockAdapter([textResponse('after')])
     const adapter = new MockAdapter([textResponse('after')])
     const ctx = await harness(adapter)
     const ctx = await harness(adapter)

+ 8 - 4
packages/core/agent/src/types.ts

@@ -96,10 +96,14 @@ export interface HookContext {
  * - `allow` proceeds with the prompt; optional `content` REPLACES the prompt
  * - `allow` proceeds with the prompt; optional `content` REPLACES the prompt
  *   bytes (a rewrite), and optional `additionalContext` is `inject()`ed as a
  *   bytes (a rewrite), and optional `additionalContext` is `inject()`ed as a
  *   separate `context/message` the next request also sees.
  *   separate `context/message` the next request also sees.
- * - `block` drops the prompt entirely; `reason` is the durable record of why.
- *   A batch whose every prompt is blocked still opens a zero-step turn that ends
- *   with {@link TurnEndReason} `rejected` (so the boundary stays balanced and a
- *   UI can render "blocked by hook").
+ * - `block` drops the prompt (it never becomes a `user/message`); `reason` is
+ *   the durable record of why. The loop appends a `prompt/blocked` session event
+ *   (carrying the original content, source, and `reason`) in place of the
+ *   dropped `user/message`, so the veto survives replay even in a MIXED batch
+ *   where a sibling prompt is allowed. A batch whose EVERY prompt is blocked
+ *   additionally opens a zero-step turn that ends with {@link TurnEndReason}
+ *   `rejected` (so the boundary stays balanced and a UI can render "blocked by
+ *   hook").
  */
  */
 export type PromptDecision =
 export type PromptDecision =
   | { kind: 'allow'; content?: ContentBlock[]; additionalContext?: HookContext }
   | { kind: 'allow'; content?: ContentBlock[]; additionalContext?: HookContext }

+ 1 - 1
packages/core/session/README.md

@@ -49,7 +49,7 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
 
 
 ### Session event vocabulary (`types.ts`)
 ### Session event vocabulary (`types.ts`)
 
 
-The append-only log: `turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `assistant/message`, `assistant/chunk`, `tool/call`, `tool/result`, `steering/message`, `context/message`, `todo/write`. Token usage rides on `assistant/message.usage`; an operational error's step is on `turn/end.reason` for `kind: 'error'`.
+The append-only log: `turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `prompt/blocked`, `assistant/message`, `assistant/chunk`, `tool/call`, `tool/result`, `steering/message`, `context/message`, `todo/write`. Token usage rides on `assistant/message.usage`; an operational error's step is on `turn/end.reason` for `kind: 'error'`.
 
 
 Merge-extensible via `SessionEventMap` — the compaction seam adds `compact/start`, `compact/summary`, and `compact/end`.
 Merge-extensible via `SessionEventMap` — the compaction seam adds `compact/start`, `compact/summary`, and `compact/end`.
 
 

+ 11 - 0
packages/core/session/src/types.ts

@@ -204,6 +204,17 @@ export interface SessionEventMap {
   'step/end': { turn: number; step: number }
   'step/end': { turn: number; step: number }
   /** A user-visible prompt (queued message drained at turn start). */
   /** A user-visible prompt (queued message drained at turn start). */
   'user/message': { content: ContentBlock[]; source: MessageSource }
   'user/message': { content: ContentBlock[]; source: MessageSource }
+  /**
+   * 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
+   * ({@link PromptDecision} `block.reason`). NOT a {@link SurfaceEventType}: a
+   * blocked prompt produces no LLM message and never reaches `deriveMessages()`.
+   */
+  'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string }
   /**
   /**
    * In-session context injection (file-change notices, subdir AGENTS.md,
    * In-session context injection (file-change notices, subdir AGENTS.md,
    * skill content, cron notifications, …). Rendered into the derived history
    * skill content, cron notifications, …). Rendered into the derived history

+ 1 - 1
packages/support/ui-stdio/README.md

@@ -25,7 +25,7 @@ This package consolidates what were two near-identical copies under `examples/ec
 Rendering is **global** — every agent's events are written to stdout, not just `config.agent`'s. `config.agent` scopes only *input* (which agent stdin drives) and the EOF-exit gate; the single-agent demos this serves have just one agent, so the distinction is moot for them. (A multi-agent UI that needs per-agent panes would filter these handlers by the agent argument — deliberately out of scope here.)
 Rendering is **global** — every agent's events are written to stdout, not just `config.agent`'s. `config.agent` scopes only *input* (which agent stdin drives) and the EOF-exit gate; the single-agent demos this serves have just one agent, so the distinction is moot for them. (A multi-agent UI that needs per-agent panes would filter these handlers by the agent argument — deliberately out of scope here.)
 
 
 - `agent/stream-chunk` — `text-delta` is written verbatim; `reasoning-delta` is wrapped in the dim SGR (`\x1B[2m … \x1B[0m`) so the chain-of-thought is visually subordinate to the answer. Reasoning rendering is inert when no `reasoning-delta` chunks arrive (e.g. a mock model), so it is always on.
 - `agent/stream-chunk` — `text-delta` is written verbatim; `reasoning-delta` is wrapped in the dim SGR (`\x1B[2m … \x1B[0m`) so the chain-of-thought is visually subordinate to the answer. Reasoning rendering is inert when no `reasoning-delta` chunks arrive (e.g. a mock model), so it is always on.
-- `session/event` — the durable transcript feed drives all boundary and content rendering: `turn/start` prints a `[<agent> turn N]` header (the short agent label comes from an `agent/created`→id map, since the turn event carries only the turn number), `turn/end` prints the trailing `> ` prompt, `tool/call` renders `[tool call] name(args)`, `tool/result` renders the joined text blocks as `[tool result] …`, and `todo/write` renders a glyphed checklist.
+- `session/event` — the durable transcript feed drives all boundary and content rendering: `turn/start` prints a `[<agent> turn N]` header (the short agent label comes from a session-id→agent-id map seeded from `ctx.agents.list()` at install and kept live via `agent/created`/`agent/disposed`, since the turn event carries only the turn number), `turn/end` prints the trailing `> ` prompt, `tool/call` renders `[tool call] name(args)`, `tool/result` renders the joined text blocks as `[tool result] …`, and `todo/write` renders a glyphed checklist.
 
 
 ## The I/O seam
 ## The I/O seam
 
 

+ 7 - 1
packages/support/ui-stdio/src/index.ts

@@ -80,8 +80,14 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
   // number, so to print the short agent id (`[main turn 1]`) we map the
   // number, so to print the short agent id (`[main turn 1]`) we map the
   // session's id to its agent's id. The session id is not reliably the agent id
   // session's id to its agent's id. The session id is not reliably the agent id
   // (a session can be created with an explicit/client-supplied id), so build the
   // (a session can be created with an explicit/client-supplied id), so build the
-  // map from `agent/created` rather than parsing the id string.
+  // map from `agent/created` rather than parsing the id string. Seed from the
+  // registry's current agents first: an agent registered before this plugin
+  // installed (e.g. the pre-created `main` agent, or any agent surviving an HMR
+  // reload of just this fiber) already fired its `agent/created`, so the live
+  // listener alone would miss it and its turns would fall back to the raw
+  // session id.
   const labelBySession = new Map<string, string>()
   const labelBySession = new Map<string, string>()
+  for (const agent of ctx.agents.list()) labelBySession.set(agent.session.header.id, agent.id)
   ctx.on('agent/created', (agent) => { labelBySession.set(agent.session.header.id, agent.id) })
   ctx.on('agent/created', (agent) => { labelBySession.set(agent.session.header.id, agent.id) })
   ctx.on('agent/disposed', (agent) => { labelBySession.delete(agent.session.header.id) })
   ctx.on('agent/disposed', (agent) => { labelBySession.delete(agent.session.header.id) })
 
 

+ 3 - 0
packages/support/ui-stdio/tests/readline.spec.ts

@@ -16,6 +16,9 @@ function fakeContext(): Context {
   return {
   return {
     on: vi.fn(() => vi.fn()),
     on: vi.fn(() => vi.fn()),
     effect: vi.fn((callback: () => () => void) => callback()),
     effect: vi.fn((callback: () => () => void) => callback()),
+    // The UI seeds its label map from the registry at install; this suite only
+    // exercises readline terminal-mode selection, so an empty roster suffices.
+    agents: { list: vi.fn(() => []) },
   } as unknown as Context
   } as unknown as Context
 }
 }
 
 

+ 20 - 0
packages/support/ui-stdio/tests/ui-stdio.spec.ts

@@ -149,6 +149,26 @@ describe('createStdioChat rendering', () => {
     expect(out.text()).toContain('[orphan-session turn 1] ')
     expect(out.text()).toContain('[orphan-session turn 1] ')
   })
   })
 
 
+  it('seeds labels for agents already registered before the UI installs', async () => {
+    // The pre-created `main` agent (and any agent surviving an HMR reload of just
+    // this fiber) fired its `agent/created` before the UI's listener existed, so
+    // the live listener alone would miss it. Seeding from `ctx.agents.list()` at
+    // install time is what keeps its turn header showing `[main turn N]` instead
+    // of the raw session id.
+    const ctx = new Context()
+    await ctx.plugin(AgentRegistry)
+    const agent = makeAgent('main')
+    ctx.agents.register(agent) // registered BEFORE the UI plugin below
+    const { runtime, out } = makeRuntime()
+    await ctx.plugin(Object.assign((inner: Context) => {
+      createStdioChat(inner, CONFIG, runtime)
+    }, { inject: ['agents'] }))
+    ctx.emit('session/event', makeSession('main'), {
+      type: 'turn/start', seq: 1, time: 0, data: { turn: 5, trigger: { kind: 'message' } },
+    } as SessionEvent)
+    expect(out.text()).toContain('[main turn 5] ')
+  })
+
   it('resets dim styling at turn/end if a turn ends mid-reasoning', async () => {
   it('resets dim styling at turn/end if a turn ends mid-reasoning', async () => {
     const { ctx, out } = await setup()
     const { ctx, out } = await setup()
     const agent = makeAgent('main')
     const agent = makeAgent('main')