Three dev-mode invariants close the leak-by-default regression class at
runtime: (1) every scope-filtered event family must dispatch with a scope
carrier — a bare dispatch throws at the call site naming the carrier rule;
(2) where the subject is recoverable from the arguments (agent/*, the tool
pipeline, prompt assembly) the carrier's key must BE that subject, and an
assembly context must never carry agent without scope (use
assembleContextFor); (3) a turn/start logged before the owning agent's
agent/session-start is the setup-drives teaching error (setup registers the
scoped world, it never drives the agent).
SubagentStartRequest gains persona (capability-gated like toolFilter); the
in-process driver composes the child's scoped world in the factory's setup
window — persona as a scoped shadowing deployment:persona section,
toolFilter as a scoped tools.restrict() (loud unknown-name validation),
outputSchema as the scoped structured runtime. spawn/fork now advertise
every start-time capability; ACP stays all-false. A parent-scope teardown
effect links each child to its parent through the memoized handle, so a
disposed parent reaches its whole subtree even if the delegating tool's
finally never runs; subagent/start|end dispatch in the delegating parent's
scope.
structured.ts loses the placeholder schema, the final-assembly swap/strip,
the refcounted root runtime, and the WeakMap state: each child registers
its OWN capture tool (real schema), instruction section, and enforcement
listeners on child.ctx, riding the child's fiber. The commit listener is
call-keyed (a stale stage from a short-circuited post-execute chain is
dropped, never promoted on a later call), and one scoped prepend re-assert
listener preserves the final-assembly guarantee against a stripping global
listener.
tool-subagent gains persona/toolFilter/maxDepth passthrough config —
deny-listing the delegation tool (or maxDepth) is how a deployment bounds
recursion; the omitted-toolFilter schema key is forced absent (a
materialized {} would mean an empty allow-list, i.e. deny-everything).
The producer/consumer matrix reads dispatch sites statically; the fused
agentEvents dispatcher, the agent's loopCtx handle, the session store's
captured emitCtx, and carrier-first argument lists were invisible to it,
silently dropping agent-loop/session as producers of every scoped event.
The generator now recognizes those spellings; the Agent type-equiv doc
block gains readonly ctx.
Every live agent owns a dsh-scope context (Agent.ctx, key = the agent),
minted inside the loop's composite lifecycle effect: registrations through
it are agent-visible and agent-lifetime, and agent.ctx listeners hear only
that agent's dispatches. The composite yields the scope's raw disposer
first (identity-nested, no un-nested window), then session entry (scoped
enter captures the session carrier), then registration; teardown runs
stop/drain -> unregister -> detach session -> unwind scope, keeping
store/registry rollback synchronous on every failure path.
CreateAgentOptions.setup(agentCtx) runs after the scope is minted and the
agent registered, before agent/session-start and the loop start — the slot
where a creator composes the agent's scoped world (persona sections,
restrict(), scoped tools); a throwing setup unwinds inside the rollback
boundary. Setup registers, it never drives.
agentEvents(ctx, agent) fuses the scope carrier with the injected subject
argument for every agent/* dispatch (the correct dispatch is the shortest
spelling); assembleContextFor(agent) pairs the agent DX field with the
scope layer selector. All loop/agent/registry dispatch sites converted;
agent/* event declarations carry this: Scoped<Agent>; ctx.agent is a safe
root accessor defaulting undefined, shadowed by each agent context.
dsh-tools and dsh-system-prompt gain a per-scope registration layer over
dsh-scope: a registration through a scoped context files into that scope,
shadows a same-named global contribution for that scope (per-agent persona
and tool variants), and unwinds with the scope. tools.restrict() masks the
global surface per scope (snapshot-at-registration, loud unknown-name
validation, intersection composition; scoped grants bypass). One visibility
function feeds schemas/get/execute, so prompt, presentation, and dispatch
can never disagree; out-of-view executes as UNKNOWN_TOOL.
Prompt tool providers now receive the AssembleContext and return
{schemas, knownNames}: toolOrder validates against the pre-restriction name
universe (a typo fails every assembly loudly) while ordering operates on
the post-restriction schemas (a restricted-away tool is a normal absence).
dsh-session captures each session's dispatch carrier at enter() from the
entering context's scope tag, and the new sessions.flush(session) owns the
awaited session/flush dispatch. tools/pre|post-execute and
system-prompt/assemble dispatch with scope carriers keyed by their subject;
session/created|event|flush by the owning session's scope.
The seam shipped without its own RFC — the reconstructable-requests RFC
was amended with the mechanics, but the decision record (why a
compose-once frozen prefix, and what the per-request before/after shape,
a system-prompt section, a durable history opener, per-turn composition,
and a dedicated session event each lost to) had no home. Implemented
lifecycle, feature class, dated to the first commit of the work.
A reviewer read "TypeScript via host-side type-strip" and reasonably
asked what typing buys if nothing checks it — the group README never
said the annotations are advisory by design. The row now states it; the
rationale stays in the RFC and the enforcement story (per-dispatch
validateArgs) in the dsh-tools README.
createScope(ctx, key) mints a tagged context over a synchronously-usable
no-op-plugin fiber (one fact drives visibility AND lifetime); scopeOf reads
the tag through the prototype chain; scopeTarget(base, key) builds the
scope-filtered dispatch carrier over cordis Context.filter, composing the
base's own filter, branded Scoped<T> and runtime-marked for the dev
invariants. Scope.rawDispose exposes the exact cordis disposer so a
composite effect can nest the scope's teardown at its yield position.
The seam gained sessionPrefix between fullSystemPrompt and signal in
18d478bc, but the compact README member table and the compaction
core-data-structures page still showed the 3-arg form and listed only
agent/system/signal as what pre-step supplies. The generated service
catalog was already correct; only these two prose homes drifted.
[P1] review finding: the run-scoped abort + queue drain ran only after
runtime.run() FULFILLED, so a backend that starts a binding call and
then throws left the sub-dispatch running past run_code's settlement —
its tool/code-dispatch event could append after the parent call
returned, breaking the drain-before-return contract. The quiescence
pair now lives in a finally around runtime.run(); the folded queue tail
keeps the drain from masking the thrown error. Pinned by a test whose
fake runtime fails mid-flight: pre-fix it returns in milliseconds with
the slow tool still running.
Two [P1] review findings on the worker runtime:
- maxValueBytes gated and sliced the rendered fallback by UTF-16 code
units, so a multibyte string ("€€€€" under a 4-byte cap) crossed whole
and a truncated multibyte rendering could still run ~3x over budget.
New truncateUtf8Bytes cuts at code-point boundaries under a real byte
budget; prepareValue's fallback and the host's forged-error-text bound
both use it, and the VALUE_RENDER_SLACK comment drops its now-obsolete
"sliced by characters" wrinkle.
- The patched stream write dropped Node's optional encoding/callback
arguments, so a program awaiting flush completion
(write(chunk, resolve)) hung to the wall ceiling and misreported as a
timeout. The shim now fires the callback asynchronously once the chunk
is admitted — including for writes the exhausted budget drops.
A cancel/dispose landing inside the first agent/session-prefix waterfall
used to commit the listener chain's return value to the instance cache
before the interruption check dropped the turn; an abort-aware listener's
degraded fallback would then ship on every later request of the instance.
The commit now happens only after the composition survives the
interruption check — the cache only ever holds a fully composed prefix,
and the next turn recomposes under a live signal.
ds-review-bot critical (follow-up): on the first step of a resumed or
seeded/forked instance, auto-compaction ran before runStep composed
this instance's prefix, so the gate read the PREVIOUS instance's logged
prefix from the header fold — a contributor that grew across
resume/fork (skills added, AGENTS.md grown: exactly the
environment-dependent case) could under-gate and ship an over-window
first request.
The loop now composes agent/session-prefix before the instance's first
agent/pre-step (still once per instance; runStep just reads the cache),
and agent/pre-step carries the composed prefix to its listeners.
CompactService.compactIfNeeded gains the sessionPrefix parameter;
BasicCompactService.estimatePressure gates on the handed value — the
header-fold read is gone, so the estimate is exact at every step
including a resumed/forked instance's first. Composition moving before
the boundary snapshot also means a composing listener's session append
now joins the CURRENT request (documented on the seam).
New coverage: composition precedes pre-step and the seam receives the
composed prefix; cancel and disposal landing inside the composition
window drop the step cleanly; the compact gate test hands the prefix
directly.
ds-review-bot critical: compactIfNeeded estimated pressure from the
derived history + system prompt only, but every loop-built request also
carries EpochHeader.messagePrefix in front of the history — a
deployment at the window edge would under-estimate by exactly the
prefix, skip compaction, and ship an over-window request.
BasicCompactService now gates on estimatePressure(): the session prefix
read from the log's folded header + the derived history + the system
prompt. The fold is exact from the instance's second request on (and
from a resumed instance's first — the previous instance logged its
prefix); it is absent only before a fresh session's first request,
where the history is a single prompt and compaction is moot. Compaction
itself still shrinks history only — a prefix that alone approaches the
window is a configuration error no compactor fixes, same as the
documented single-unit-overflow stance.
The credential env scrub (SENSITIVE_ENV_PATTERN/buildChildEnv), the
spawn-failure capture (spawnFailure), the child-exit waits
(waitForExit/exitsWithin), and the stdin-EOF -> SIGTERM -> SIGKILL dispose
ladder move out of subagent-acp into a new pure library package (the
subagent-inprocess shape), with the ladder taking its two grace periods as
parameters — defaults stay in the plugin Config. New isolated-config-dir
helpers (mkdtemp create, best-effort remove; a pinned dir is never removed)
land alongside for the CLAUDE_CONFIG_DIR / CODEX_HOME redirection the RFC
names.
The ACP backend migrates onto the library with no semantic change: its suite
passes with import-path edits only. bash-local keeps its sibling copy, per
the RFC's blast-radius call.
RFC: docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md
Two ds-review-bot findings:
The seam JSDoc claimed extending 'await next()' composes in
registration order — false for the append form: the waterfall unwinds
innermost-first, so appending places later-registered contributions
first. The canonical contribution is now documented as the PREPEND
'[mine, ...await next()]' (registration order on the wire), with the
append form's reverse-order behavior stated explicitly; the ordering
test now uses the canonical pattern in both listeners.
scrubRequestHeaders tokenized only system/tools, so a fixture recording
a composed session prefix would carry its raw text (workspace-specific
churn/leak). The scrubber now maps each header/delta messagePrefix
entry to a {{messagePrefix}} token — count stays a structural fact,
absence stays absent, the empty-array transition stays visible — with
normalize.spec coverage for the header, delta, absence, and odd-shape
paths.
Brings in the refreshed base (master merged through the stack after #203
and #205 landed), including the acp-snapshot extraction (#204), and
re-ports this PR's snapshot-suite extensions onto the extracted package:
- dsh-acp-snapshot's Scenario gains headerClass and configPath; the suite
factory pins the request header PER CLASS (construction rejects a
missing or duplicated class pin), forwards a scenario's configPath to
the harness (RunOptions.configPath overrides AgentUnderTest.configPath),
and a new fixtures meta-test asserts every pinning fixture carries
exactly one request/header and no deltas.
- The acp-agent example's thin scenario table re-registers code-mode-turn
and both-mode-turn with their overlay configs and per-class pins; the
committed fixtures replay unchanged.
- The package's synthetic suites cover the new surface (explicit
headerClass on one suite, the default on the other, a configPath
override through the fake bin, and the two construction throws).
The master merge added a code-runtime/ package row while this branch adds
the timeout/ row; together they push packages/README.md over its 605-word
ceiling. Condense the timeout/ row to the terse sibling style and raise the
ceiling 605->610 for the genuinely-new package group, mirroring how the
code-runtime work raised architecture.md's ceiling in the same spirit.
Review discussion converged on the industry shape (Claude Code caches
user context per conversation; Codex separates initial context from
diffs; Kimi appends at continuation boundaries to protect prompt
caching): stable openers belong in a compose-once prefix, mid-session
changes belong in append-only history — not in a per-request slot.
agent/session-prefix fires ONCE per loop instance, lazily on its first
request-building step: the composed Message[] is deep-frozen, cached on
the transmission bookkeeping, recorded as EpochHeader.messagePrefix on
the anchoring 'initial'/'resume' snapshot, and reused verbatim for
every request the instance sends — prefix stability is structural, not
a producer discipline, and a resume recomposes with attributable drift.
The request is messagePrefix + boundary snapshot.
The per-step RequestAdvice/RequestAdviceContext surface and the
messageSuffix header field are dropped: the tail slot had no consumer,
and every current update pattern (new AGENTS.md discovered, memory
update, skills change) routes through the existing append-only history
channels — inject(), tools/post-execute additionalContext,
prompt-submit additionalContext — each paid once and prefix-cached
thereafter. The messagePrefix delta arm stays for codec totality; the
loop never produces one in practice.
The zero-config enforcer no longer imports schemastery (its Config was
removed); knip flags the stale dependency. Remove it from the manifest
and sync the lockfile.
The RFC's deployment-policy decision is unchanged; state the current
mechanism in place — the per-tool budget is declared on ToolDefinition
(timeoutMs, set by the owning tool plugin from its config) and the
enforcer is zero-config, so a mistyped tool name is impossible.
Regenerate config-catalog (timeout-policy -> no-config; tool-web gains
fetch/searchTimeoutMs), the event graph (tools/change loses its
timeout-policy consumer), the ToolDefinition type-equiv block, and a
source-line drift in the cordis services catalog.
fetchTimeoutMs/searchTimeoutMs (default 30000) resolve to each tool's
ToolDefinition.timeoutMs, moving the budget's declaration home onto the
owning tool plugin and preserving per-tool deployment override without a
mistypable central tool-name map.
The enforcer now reads ctx.tools.get(exec.name).timeoutMs instead of a
free-text tool-name config map, so a mistyped name is impossible and the
tools/change warn-once apparatus is gone. exec.name always resolves in the
registry during dispatch, so there is no unknown-name path to warn about.