Review finding, valid — the previous SDK fix covered only the
non-default branch: in the default mode under Code Mode the wire filter
dropped exit_plan_mode but the registry-rendered tools:sdk section
still advertised its binding, offering default-mode agents a call that
can only error and breaking the byte-identical claim (a no-dsh-mode
deployment's registry never saw the tool, so its SDK never listed it).
The SDK re-render extracts to one helper both branches share: the
non-default branch passes the mode's visibility rule, the default
branch hides exactly the exit binding. The pinning test now compares
the default-mode SDK byte-for-byte against a bare deployment without
dsh-mode — the strongest form of the invariant the RFC states.
Review follow-up on the residual the previous commit accepted — and the
acceptance was wrong, because the fix is clean: in Code Mode the SDK
section IS the soft surface (the wire carries only run_code), section
text resolves in assemble's base, and renderToolsSdk is an exported
pure renderer. The outermost wrapper therefore re-renders tools:sdk
from the same visibility predicate the wire filter applies (allowlist,
exit-IFF-plan, minus run_code mirroring the registry's own exclusion):
a plan-mode program is documented exactly the callable bindings — read
and the exit, never the denied write. The default mode leaves the
section untouched (absence of policy), both pinned by tests.
The soft layer's promise — the model is never encouraged toward a tool
the gate denies — now holds in Code Mode too; the only remaining
prompt-honesty residual is a prepend-after-load assemble listener,
where the gate still covers execution.
Review finding, valid: under the registry's Code Mode the assembly's
only wire tool is run_code, which the plan allowlist filtered out —
leaving the model with NO tools at all, the exit review included. The
composition exists today (the acp-agent example ships a code-mode
overlay), so plan mode bricked it outright.
run_code is a transport, not a capability: every bridged sub-call is
serialized back through ToolRegistry.execute() carrying the same agent,
so tools/pre-execute judges each capability individually — exactly like
native calls. Both layers now exempt it by name: the filter keeps it
visible (tests pin plan-mode Code Mode assembly = ['run_code']) and the
gate passes the wrapper while the same run's write sub-call still
denies with the plan-mode reason.
Documented residual, same class as the prepend-after-load one: the SDK
section renders from the registry's store, so a plan-mode program may
be offered bindings whose dispatch the gate then denies — nothing runs
that a native call could not.
Review finding at the seam of two surfaces this branch added to the
same stdin: with an ask_user_question (or plan-review) prompt active,
the line handler dispatched every line as the answer first, so
'/mode plan' typed mid-question was recorded as free-text feedback —
model-visible in the tool result — and the mode never changed. Command
handling now runs before answer dispatch: the command executes, the
question stays pending and still owns the next non-command line. A
literal '/mode…' free-text answer is the trade-off deliberately spent —
a swallowed command that becomes review feedback costs far more than
that contrived answer shape.
Review finding with a real in-repo instance: the structured runtime's
per-spawn final-assembly wrapper (prepend, post-next) re-injects
structured_output OUTSIDE the mode filter, so a structured child in
plan mode would see a tool the gate then denies — the soft policy and
the hard gate telling different stories. The suggested fix (make the
mode filter outermost) cannot beat that instance: prepend unshifts, so
the per-spawn listener always registers later and wraps outer.
Two-part resolution instead. Semantically, structured_output enters the
shipped plan allowlist — it is a child's pure result channel, the same
ask/report class as ask_user_question and exit_plan_mode, so the
filter, the re-injection, and the gate now agree wherever a structured
child runs in plan mode. Mechanically, the filter registers with
prepend anyway: it now wraps outside every append-registered listener
regardless of load order (regression test pins a pre-registered
post-next mutator being filtered), narrowing the documented cosmetic
residual to prepend-after-load listeners only, where the gate still
covers execution. Severity note: no execution breach existed — the gate
held throughout; this closes the prompt-honesty gap.
The three-entry cordis.yml (dsh-sandbox-local + dsh-bash-sandbox at a
read-only default + dsh-approval) served over ACP: the first live approval
composition. Recorded snapshot scenarios pin the wire end to end —
config-options advertisement, the mode-switching arc as the suite pinned
header (both switches, the prompt-section delta, one changed-by-the-user
notice per knob, a confined write landing under the switched mode), and
both escalation branches over scripted permissionAnswers (a grant runs
confined under workspace-write; a rejection executes nothing and pins the
fail-closed text). The with-key escalation e2e drives a real model +
real runner + the real bridge answerer, world-verified; ci.yml snapshot
lane and e2e.yml install bubblewrap so the confined replays actually
execute.
Both RFCs move to implemented/ (Decision/Consequences form, deferred
phases tracked in their own sections), with every cross-reference flipped.
Live-session feedback (a real Zed elicitation round-trip): the model
presented its finished plan as a plain reply and asked the USER to
switch modes — the exact reversal the roadmap warns about — because the
shipped section's 'present it with the exit_plan_mode tool' read as a
suggestion. The section now says a finished plan is delivered by
calling exit_plan_mode, preferred over pasting it as a plain reply or
asking the user to switch modes — firmer, without imperatives.
ask_user_question enters the shipped plan allowlist (asking is
read-only-safe), and the section points a blocked decision at it. The
plan-acp-agent example composes the bash family (default mode only —
plan's allowlist keeps excluding it, so the two modes now demo a real
difference) plus tool-ask-user; both recorded scenarios re-recorded:
the pin now shows plan = [ask_user_question, exit_plan_mode, read,
todo_write] and post-exit default = the full eight-tool surface.
Review finding: the exit tool's direct mode/set append flipped the
folded mode while the loop could still execute further tool calls from
the SAME assistant response — a same-batch exit_plan_mode + write pair
would sail past tools/pre-execute under 'default' even though the
request was assembled under the plan-shaped header. That broke the
design's own invariant (a step's executions run under the mode its
assembly folded), which the pending-intent flush was built to hold for
user flips.
The tool now records the switch as a pending intent like every other
writer, flushed at this step's end (still in-turn); pending intents
carry a narrate flag so the exit's flush stays silent — the tool result
is its narration — while user flips keep the coalesced boundary notice.
The gate, folding the logged mode only, now provably covers the whole
batch: regression test pins approve-then-write-in-the-same-batch as
denied, and the widened toolset still arrives on the next step.
The recorded scenarios are re-recorded: the fixture now shows mode/set
landing after step/end, before the widened fallback header.
The with-key recording session the RFC deferred. plan-mode is the 'plan'
header class's pinning scenario and necessarily carries BOTH header
shapes verbatim: the plan-shaped initial snapshot and the widened
fallback snapshot the approved exit produces mid-turn — so the suite
factory's pin guards relax from exactly-one to at-least-one header (the
classmates' uniformity anchor is the pin's FIRST header; transition
headers are legal only in the pin, matching the sandbox stack's
precedent). plan-mode-reject pins the keep-planning branch: the
corrective isError carries the reviewer's free-text feedback verbatim
and the session stays in plan mode, one header, uniform with the pin.
Recording notes, encoded in the prompts: the model is pinned to RELATIVE
paths (a recorded absolute temp path neither replays on another host nor
normalizes — the normalizers scrub the run's own cwd, not the
recording's), and the recorded model never calls a filtered tool, so the
gate's deny path stays pinned at the unit tier — that refusal is the
behavior the soft layer exists to produce. The gen-tool-catalog
meta-test pins exit_plan_mode in the harvested schema list.
The proposal survives contact with the code with three amendments, per
the RFCs-are-proposals rule. (1) A mode transition logs a
request/header-delta only when expressible: adding exit_plan_mode
resorts the canonical tool list, and a pure reordering has no delta
form, so entering plan mode logs the full fallback snapshot — the
attributability claim holds either way. (2) The proposed/ skeleton
converts to the implemented grammar: Proposal → Decision, the roadmap's
staging (now history) drops to the standing Deferred list, and
Acceptance criteria + Risks fold into Consequences (what holds, then the
accepted costs, including the ACP v2 mode-removal migration). (3) The
two recorded scenarios stay pending a with-key session, recorded in
Deferred.
The docs tail completes: the cookbook's plan-mode row upgrades from
sketch to the shipped package, architecture.md gains the ctx.modes
capability row (ceiling 1640 → 1650: a new capability service's table
row does not fit the old budget), and every reference repoints to
implemented/.
Adopts #211 (Code Mode tools: run_code + the code/both-mode snapshot
scenarios). Tool-catalog expectations take the union (run_code +
task_*); the two new pinsHeader fixtures (code-mode-turn,
both-mode-turn) were recorded on master without the task runtime, so
they are re-pinned KEYLESSLY by replaying their recorded chunks against
the merged tree (same procedure as text-turn) — the fixture diff is
exactly the header delta: task tool schemas, the tool:tasks prompt
section, and the bash background wording.
Master advanced 11 commits mid-merge (the Code Mode registry integration:
mode config, run_code, the tools:sdk section, the ACP run_code cards and
unified demos). The fusion makes Code Mode scope-aware end to end:
- wireSchemas(scope): the mode-aware wire contribution is computed from the
CALLING SCOPE's visible set (scoped tools join, shadowing and restrictions
apply) and feeds the {schemas, knownNames} provider protocol.
- knownNames under the mode collapse: a per-scope RESTRICTION is runtime
state, so the universe stays pre-restriction (a restricted-away tool in
toolOrder is a normal absence) — but the MODE collapse is deployment
config, so under mode 'code' the universe is [run_code] and a toolOrder
naming a native tool fails every assembly loud (master's tested decision,
kept).
- The tools:sdk section renders per assembly CONTEXT: the SDK declares
exactly the calling agent's callable set, using the section-text provider
signature this branch already had.
- run_code bindings enumerate schemas(exec.agent) — a program can bind
exactly what its prompt promised; sub-dispatches already threaded
exec.agent through registry.execute, so scoped resolution and carriers
flow unchanged.
- dsh-tools declares both sides' new deps (dsh-scope + dsh-session);
lockfile and all generated catalogs/graphs/api-catalog regenerated.
Gates green on the merged tree: typecheck, lint, per-file 100% coverage
(2710 tests), snapshots (41), doc-sync, module graph, build, hygiene, demo
smoke.
enableRunInBackground: false removed the parameter from the advertised
schema only — the arg validator deliberately allows undeclared keys, so
a caller (or a model that has seen the parameter elsewhere) could still
force run_in_background: true and start background work past the
deployment's opt-out, in both tool-bash and tool-subagent. Both
producers now refuse the forced key loud in execute(); tests pin the
refusal (and that nothing spawns) alongside the untouched foreground
path; the schema-omission-is-advertising rule is recorded in the
runtime RFC and both READMEs.
Master brought 50 commits (the tool-cordis group, dsh-code-runtime + worker,
the tools/execute around-dispatch seam + timeout-policy, repeat-tool-guard,
agent/session-prefix, the ui reorganization). Beyond the ten textual
conflicts, the merge reconciles master's new seams with this branch's
scoped-registration world:
- tools/execute (new waterfall around core dispatch): dispatched with the
SAME exec.agent carrier as the pre/post waterfalls — an agent.ctx wrapper
times/retries only its own agent's calls — and its base thunk resolves the
tool through the caller's visible view (get(exec.name, exec.agent)), so a
scoped/shadowed tool dispatches and a restricted-away global stays
UNKNOWN_TOOL. Declared this: Scoped<ToolRegistry> with the scope-filtered
doc sentence; invariants table + verify-scoped-dispatch pin it (21 events).
- agent/session-prefix (new waterfall, once per loop instance): composed via
the fused agentEvents dispatcher (scope-filtered like every agent-subject
event), declared this: Scoped<Agent>, table-pinned. agent/pre-step keeps
master's new sessionPrefix parameter with this branch's Scoped this.
- timeout-policy reads the budget through the caller's visible view
(get(exec.name, exec.agent)): a scoped tool's own timeoutMs governs its
calls; a global name-twin's budget is never misapplied to a shadowing
per-agent variant.
- tool-cordis: cordis_inspect's tools section lists the CALLING agent's view
(its description promises "what you can call"); the sandbox tool façade's
reads resolve through the mount's own scope, mirroring where its register
lands writes; sandboxRegisterTool's return type carries the exact-disposer
union honestly. dsh-scope declared as peer+dev with the project reference.
- doc-sync chain unions master's verify-cordis-api with this branch's
verify-scoped-dispatch; the generated catalogs, event matrix (the
zero-dispatcher guard passes over master's new events), module graph, and
the cordis api-catalog are regenerated on the merged surface.
Full gate sequence green on the merged tree: typecheck, lint, per-file 100%
coverage (2668 tests), snapshots (38), doc-sync, module graph, build,
hygiene, demo smoke.
Two windows where settle() could read a live waiter count, mark the
task reported (suppressing the completion notice), and then watch that
waiter reject with 'wait aborted' delivering nothing — leaving the
owning session with no terminal notification at all:
- abort and settlement in the same tick, settle continuation ordered
first: the waiter now un-counts itself SYNCHRONOUSLY inside onAbort
(the finally decrement alone lands a microtask too late), so the
settle path sees no live waiter and the notice fires;
- abort landing after settlement but before the wait's resolve
microtask: the wait now resolves and DELIVERS the terminal snapshot
it owes instead of rejecting (settlement suppressed the notice on
this waiter's behalf).
Both windows pinned by deterministic tests that fail on the previous
implementation; wait()'s abort contract updated in JSDoc/README/RFC.
Adopts #220 (tool-cordis + the gen-cordis-api gate: the runtime API
catalog regenerated with ctx.tasks/onCleanup on it) and #225 (shared
transcript renderer). Tool-catalog expectations take the union
(cordis_* + task_*); packages/README budget adopts master's 660
ceiling, which absorbs both new group rows.
The sandbox execute wrapper JSON round-tripped the return and blindly cast it
to ToolExecuteReturn. A JSON-valid but wrong-shape return — a bare string,
{ content: 'ok' }, blocks without a type tag — sailed through: the registry
spreads result.content, so { content: 'ok' } became ['o','k'], passed the
session log's isJsonValue gate, and the DeepSeek serializer then flattened it
to '(no output)' — silent corruption of the next model request and every
replay, instead of a contained tool error.
The round-tripped value is now shape-checked against the two ToolExecuteReturn
forms (array of content blocks, or { content: blocks, meta? }); block checks
are structural only (plain object + string type tag) because the ContentBlock
union is merge-extensible. A wrong shape — and the formerly cryptic
forgot-return/bare-string cases — fails that one call with a teaching error
echoing a truncated preview of what was returned and the two valid forms.
New specs pin the object-form pass-through (meta included), six rejection
shapes, and the preview truncation; per-file 100% coverage holds.
start({ kind, label, owner, run }) preflights everything that can fail
(the attachSurface fence, validation, the owner-cleanup attach) BEFORE
invoking the producer's run() starter, then commits atomically —
'work started but never got a collectable id' is now structurally
impossible instead of a producer try/catch rollback obligation (the
P1 review fix, rebuilt on #185's declare/execute split). Producers
lose their catch-wraps; the leak tests now pin the stronger property
that a failed preflight never spawns anything. TaskRegistration splits
into TaskStart (identity + run) and TaskHooks (cancel/done/readOutput);
docs, type-equiv manifest, catalogs, and both RFCs move with it.
ctx.tasks.wait arms a deadline() fusing the caller's abort with the
wait timeout and classifies the outcome with timeoutOf scoped to the
new TASK_WAIT_TIMEOUT code: a wait timeout resolves to the live
snapshot (the task keeps running), a caller abort rejects the wait —
same contract, no hand-rolled timer/listener plumbing, and a nested
foreign deadline can no longer misread as a wait timeout. task_output
deliberately declares NO ToolDefinition.timeoutMs: timeout-policy
turns a timed-out call into a structured TOOL_TIMEOUT failure, but a
timed-out wait is a SUCCESS that must still report [status: running]
(decision recorded in the runtime RFC alternatives).
Adopts #185 (dsh-timeout: clampTimeout/deadline/timeoutOf drive bash
run() timeout classification; runBash loses its own timer) and #108
(ask_user_question) across the task-runtime rework: bash-local keeps
the BashProcess handle shape with master's deadline mechanics, tool
catalogs/expectations carry both the task_* and ask-user tools, and
generated docs are regenerated on the union.
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
Implements docs/rfc/implemented/feature/2026-07-09-bash-backed-grep-glob-
discovery.md: model-facing glob/grep in a new @deepseek-ai/dsh-tool-fs-search
package, executing fixed ripgrep templates through ctx.bash.resolve/run —
not ctx.fs provider methods — so filesystem backends stay free of a search
contract and sandboxed/remote executors substitute cleanly. The tools never
call ctx.bash.start(); the tool layer owns quoting (one singleQuote safety
boundary), rg --json parsing, ItemRetainer/TextRetainer retention, and the
first tool-owned ctx.spillFiles.saveText() handoff (item-level retention the
generic post-execute spill policy cannot recover).
RFC amendments on the way to implemented/: a shared src/search-core.ts (the
SEARCH_* vocabulary + bash-run/raw-spill/spill plumbing was byte-identical
across both tools — the missed-extraction smell), and a snapshot-gap note:
wiring the acp-agent tree changes the assembled prompt, so goldens need a
keyed re-record; the spill notice text is pinned by unit tests instead and
only the coding-agent example ships the tools for now.
P1 review finding: extractMeta timed only the literal's vm evaluation;
materializing the RESULT then read properties ordinarily on the HOST
stack, so a meta literal smuggling a getter (get name() { while(true){} })
could wedge the host outside any timeout — defeating the exact spin
isolation the worker thread exists for.
Rather than harden the evaluator (descriptor walks, AST validation),
delete the mechanism: the workflow's identity now reaches the seam as a
plain JSON field (WorkflowStartRequest.meta), carried by the tool as a
schema-validated `meta` object parameter the model fills directly. The
engine only shape-validates data (validateMeta, every violation named)
and pre-parses the body; the scanner, the vm evaluation, and the
host-side materialization are gone, and with them the hole. A body
still opening with a Claude Code-style `export const meta` statement
gets a pointed SCRIPT_PARSE message (the likeliest authoring slip; a
CC script's body stays drop-in, only its meta header moves into the
parameter). syncTimeoutMs now governs exactly one thing: the initial
synchronous slice inside the worker.
The RFC's decision section is rewritten in place (implemented-RFC
rule); the embedded-meta format moves to alternatives-considered with
the hole as the reason. Tool description, presentation (title now reads
meta.name directly — the textual sniff is gone), seam vocabulary docs,
and catalogs follow.
Pure mechanical rename now that the package's internals are the
worker-thread engine: directory, package name, spec/e2e filenames,
module tags and logger prefixes, tsconfig/knip/run-gates/AGENTS.md
references, example cordis.yml plugin ids, doc links; catalogs
regenerated and the lockfile refreshed.
The outer ring catches up with the engine swap (the package's own
README/JSDoc rode the port commit):
- Seam module doc and README name the worker-thread engine as THE
implementation, with isolated-vm/separate-process sandboxing as the
deferred hardening; the seam service doc states the holder-owned-runs
contract (engine-fiber disposal deliberately leaves live runs to
their holders).
- Seam contract precision: agentsStarted documents the termination-path
degradation to the host-observed count; the events section scopes the
agent-start/agent-end pair to calls that STARTED a child run;
WorkflowRun wording drops the vm-era abandonment language.
- The dynamic-workflows RFC is rewritten in place to the shipped
mechanism (implemented-RFC rule): why worker threads, the thread's
concrete buys, the in-process node:vm first cut recorded under
alternatives considered; the tool section describes the usage policy
as the tool's own prompt section.
- gen-doc-graphs: six workflow/* DYNAMIC_EVENT_DISPATCHERS entries (the
catalog no longer claims nothing dispatches them) and the seam-note
wording; core-data-structures gains its workflow.md index row;
packages/README + AGENTS.md layout line + example cordis.yml comments
say worker-thread; catalogs regenerated.
The sandbox docs overclaimed a containment contract the design never makes:
"capability access is routed through cordis services, never Node built-ins,
so everything a mounted plugin does stays inspectable and disposable". The
host-realm helpers on the sandbox global (harness, console, btoa) are
reachable functions, so mount code that goes looking can reach the host realm
through one of them — accepted under the trust stance, because the ctx a
mount ultimately receives is fully privileged anyway. Reword the sandbox
module doc, the README trust stance, and the RFC sandbox-semantics section to
say exactly that: the traps and small global surface STEER honest code onto
the cordis services; they are not a security boundary.