scopeHost(ctx, services) is the sanctioned way to mint scopes in tests: it
names absent services loudly instead of the cryptic cordis without-inject
dead end, and catches the silent-no-op host (cordis resolves a
dependency-pending fiber's await without running the inject callback).
The ACP ToolPresenter resolves presentations through the session agent's
view (tools.get(name, agent)) so a scoped/shadowed tool renders with the
same definition that executed.
verify-scoped-dispatch (doc-sync + pre-push) pins the dev-invariants
carrier table against the declaration JSDoc set: an event enforced but
undocumented, documented but unenforced, or a registry-subject notification
leaking into the table fails the build. subagent/start|end docs gain their
scoped-dispatch sentence (a real gap the gate caught on first run).
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).
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.
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.
A client-callback throw only becomes a JSON-RPC error RESPONSE to the
agent's session/request_permission — runScenario itself kept going, so a
tolerant agent could treat the error as a denial and the scenario would
pass, or worse, record: the impossible click baked into fixture and
golden, green on every replay. The mismatch is now captured as a harness
error while the agent is answered plain cancelled (a well-defined path
it cannot reinterpret), and the step loop rejects the run on it as soon
as the in-flight step settles. The spec asserts the rejection instead of
the agent-side error echo.
InputScript gains an optional permissionAnswers queue, consumed FIFO by
the harness's requestPermission handler. Each entry selects by option
KIND (allow_once, reject_once, …): option ids are agent-issued randoms a
committed script cannot know, while kinds are the ACP-stable vocabulary,
so the client maps kind → the offered optionId at answer time. An absent
or exhausted queue answers cancelled — existing scenarios and goldens
are untouched — and a scripted kind the request never offered throws,
surfacing as a JSON-RPC error on the permission request: the scenario
scripted an impossible click.
This is what lets an approval-flow suite (the sandbox composition) drive
allow/reject round-trips deterministically from input.json, per the
shared-acp-snapshot RFC.
A scripted fake ACP agent bin (tests/fixtures/fake-acp-agent.ts) speaks
real newline JSON-RPC through the REAL runScenario spawn path (tsx
loader, temp cwd, env plumbing); every behavior — prompt outcome,
session/new rejection, persisted logs, filesystem noise — comes from a
behavior.json beside the fixture, so specs script whole subprocess runs
from data. harness.spec.ts drives every step op, both expect-error arms,
the permission-stub default, env forwarding, workspace seeding, and the
harvest ordering/noise/fallback branches. suite.spec.ts runs the factory
for real at collection time: a replay suite over committed synthetic
fixtures and a record suite over a temp copy (write-back never touches
the committed tree; ACP_SNAPSHOT_SPEC_BOOTSTRAP=1 re-bootstraps it),
plus direct cases for the exported pure helpers. The suite factory's
pure helpers (childFixturePaths, fixtureContext, normalizedHeaders,
headerDeltaCount) are exported for those direct specs.
Two branches carry justified v8 ignores, both structurally unreachable:
the waiter in-bounds guard (noUncheckedIndexedAccess) and waitForExit's
already-exited race guard (both call sites sit one synchronous frame
after stdin.end()/kill()). The fake bin substitutes the session/new cwd,
not process.cwd(), into scripted logs — the realpath difference
(/private on darwin) is exactly what the real bin's header carries.
packages/support/acp-snapshot/src is at 100% statements, branches,
functions, and lines under the per-file gate.
The snapshot tier's machinery leaves examples/acp-agent/tests for
packages/support/acp-snapshot (@deepseek-ai/dsh-acp-snapshot), where the
coverage gate measures it and a second example can consume it instead of
forking it: harness.ts (runScenario, parameterized by an AgentUnderTest
{binScript, configPath, tsconfigPath} instead of module constants),
normalize.ts (moved verbatim), and suite.ts (defineAcpSnapshotSuite — the
per-scenario golden/log compares, record write-back, per-suite header pin
with its uniformity guard, and the fixture guard block, lifted from
acp.snapshot.ts). The example file collapses to its scenario table plus
one factory call; env reading (DSH_SNAPSHOT) stays at that edge.
The exactly-one-pin meta-test generalizes from the hardcoded text-turn
name to "exactly one per suite" — which scenario pins is the scenario
table's reviewable choice (per-suite pinning per the proposal RFC).
Extraction parity: pnpm run test:snapshot is 36 passed + fs-policy-reject
failing BEFORE AND AFTER (BSD-sed environment failure, reproduced at the
base commit in a clean worktree — the recorded golden's sed -i syntax is
GNU-only), with zero byte changes under examples/acp-agent/tests/snapshots/.
Coverage for the new src files lands in the next commit.
New group packages/code-runtime/ with the interface package
@deepseek-ai/dsh-code-runtime, per the Code Mode RFC: abstract CodeRuntime
service (run() resolves program failures as an error field, rejects only
for seam misuse), the CodeRunRequest/CodeBindingNamespace/CodeRunResult/
CodeLogEntry/CodeRunFailure vocabulary, and readonly language/isolation
backend descriptors. Registered in the tsconfig maps, packages/README,
architecture service map, and the doc-graph service-role classification;
catalogs regenerated.
The RFC's one forward path token to the worker package becomes an npm-name
mention until PR3 creates that directory (verify-package-paths is
drift-scoped: the now-existing group made the token checkable).
docs/architecture.md ceiling 1630 -> 1640: the doc gained a genuinely new
capability-service row; the row itself is already minimal.
The Codex simplification concern plus the duplication comment on the spawn
apply, resolved by deletion: the backend-lifetime holds are gone, so the
runtime registers at the first structured run and disposes when the last
settles — a deployment that never passes outputSchema carries no always-on
global state, and there is no per-backend acquisition block left to extract.
The driver spec now drives an INLINE spawn-shaped provider over
startInProcessRun, which removes the spawn/fork devDependencies (the
test-only workspace cycle); plugin-level structured coverage moves to the
backends' own specs (capture through the shipped plugin, mid-run backend
unload, seeded fork capture). tools.md, the driver README, and both backend
READMEs describe the run-scoped lifetime; the module-graph regenerates
without the cycle edges.
The cross-seam blocker: structured_output recorded its value in the tool
BODY, before tools/post-execute could block the call — a PostToolUse hook's
block turned the logged result into isError while readResult still returned
structured success and the continuation veto ended the turn.
Two-phase commit: the body validates and STAGES (RunState.pending); a fourth
runtime listener on tools/post-execute — prepend, so await next() returns the
composed final decision — promotes the stage to captured only on an accepted
call, and clears it on every path. A block now yields a consistent pair: the
model and log see the isError feedback, the run settles error with no
structured value, and the turn continues so the model can react. Regressions:
block denies the capture end-to-end; accept-with-replacement still commits.
Three Codex findings on json-schema.ts, one discipline:
- required-declared and every value check now use Object.hasOwn — 'in' let
inherited names (toString) satisfy required, dodge additionalProperties:
false, and validate a declared property against the value's prototype
member instead of a carried one
- isObjectLike now means PLAIN JSON object (proto chain of at most one link,
realm-agnostic): a Date annotation or a Map-as-properties no longer passes
structurally and serializes lossily — they fail loud as subset violations
- startInProcessRun asserts BEFORE the defensive structuredClone, so a
hostile schema fails as OutputSchemaError, never a raw DataCloneError
Also the type-equiv catalog gap: tools.md gains the structured-output subset
vocabulary (4 blocks) with matching manifest entries. The driver index also
drops the runtime internals from its public re-export (runs acquire it
internally; no external consumer remains — see the following commit).
Review follow-up (#196): a listed name with no registered tool was silently
ignored; misconfiguration must block work instead. The check lives in the
assembly — the earliest moment the registered tool set exists (tool plugins
register after the service constructs) and the only universal one (cordis has
no "all plugins loaded" event; registrations change at any time). assemble()
is now async so the throw surfaces as a rejection rather than a synchronous
escape from a Promise-returning method.
Blast radius, pinned by a loop-level test: the rejection reaches the turn's
outer catch — the turn closes balanced with an `error` reason, agent/error
mirrors it, no step opens, no request/header is logged, no request reaches
the adapter, and the agent returns to idle; every turn fails identically
until the config is fixed. A boot-time validation pass was considered and
rejected (recorded in the RFC). The general principle — misconfiguration
fails loud, never a silent skip — is added to AGENTS.md.
A three-dot rest entry reads as elision in a cordis.yml; the spelled-out
sentinel says what lands there. The literal now appears once in code (the
constant) and once in the value-pinning test; every other reference — the
forwarding tests included — imports TOOL_ORDER_REST, which adds the
dsh-system-prompt devDependency to the two app packages.
Review follow-up on #196.
- run-gates.ts docSyncLeafGates() gains verify-export-jsdoc — CI lanes
and the pre-push hook execute this leaf list, not the doc-sync npm
script, so the gate was previously unenforced there (proven by
SessionForkErrorCode landing undocumented via a master merge while
checks stayed green; now documented). Same wiring gap fixed for
master's verify-config-catalog, which was also missing from the list.
- The heritage exemption now recovers the base's return surface: a void
base return carried no @returns duty, so an override returning a
concrete result documents it itself (annotated overrides run the
standard check; unannotated ones are classified by the checker so
faithful void overrides need no boilerplate annotation). Three new
negative-path tests pin it; RFC and module doc updated.
- AGENTS.md states each principle inline instead of citing RFCs (eight
citations removed; high-level doc links kept) and the editing section
now carries the self-containedness rule.
- Generated catalogs/graphs regenerated for the shifted line pointers.
A name resolved through an export list (or a default-export identifier)
mapped back to its whole VariableStatement, and checkDecl walked every
declarator — so a private sibling sharing the statement with an exported
const was wrongly required to carry JSDoc.
The scope dispatch is now two-phase: requests accumulate per statement
(null = whole statement for a direct export modifier or ambient scope;
name sets union across lists, so two lists naming different declarators
of one statement both count), then each surfaced statement is checked
once with the declarator filter. Regressions pin the private-sibling
skip, the cross-list union, and the default-export sibling.
The markers are touched mid-script, so runPoint's continuation chain (child
exit → merge → the listener's detached .then) can still be in flight when the
marker poll resolves; a vitest worker that exits first leaves the inject
condition's short-circuit path uncounted. Observed as a CI-only 99.03%
branch-coverage flake on hooks-claude — surfaced by this branch shifting suite
timing, latent before it. Two macrotask rounds pin the path deterministically.
Two human review directives:
- No re-prompt. A structured child that finishes a turn cleanly without
calling structured_output settles error to the parent immediately —
readResult already carried that mapping; the nudge loop only delayed it.
Deletes the loop, its cancellation-window guard, STRUCTURED_OUTPUT_NUDGE,
and the structuredNudgeRetries Config on both backends.
- FIXME in the structured module doc: per-agent/per-session tool registry and
prompt assembly would dissolve the final-assembly enforcement dance (the
placeholder tool, the swap, the strip, the global-registration lifetime).
Codex round-3: alias prose matches the gate's strength only when the
target's own contract is prose-only. An export-import alias to a
function, class, or namespace target (or an unresolvable one) is now
refused — those carry signature/member contracts the alias cannot
hold; export the declaration directly instead. Const/enum/interface/
type-alias targets keep the self-documentation contract.
Tests pin the refusal for function, class, and namespace targets;
module doc and RFC updated.
Two bot findings on the structured runtime:
- Terminal means terminal WITHIN the step: a model response listing
structured_output before further tool calls executed those calls after the
final answer was accepted (the turn-continuation veto only fires at step
end). A third runtime listener now denies every call for a captured agent
at the tools/pre-execute gate — dispatch skipped, isError result naming the
contract. Calls preceding the capture in the same response are untouched.
- The output schema is structuredClone'd before the subset assertion: the
caller keeps its reference, so asserting and attaching the original let a
post-start() mutation drift the enforced schema away from the asserted one.
The clone pins assertion, model-visible parameters, and validation to one
value.
Codex review of the draft: the abort-on-dispose tests asserted only that
dispose() resolved and no warning fired — a regression back to untracked
fire-and-forget runs would pass both. The hook script now records its
shell PID before sleeping, and after dispose the test asserts
process.kill(pid, 0) throws: the drain resolves only after the killed
run settled (the child reaped), so ESRCH-by-dispose-return is
deterministic under the correct implementation, while an alive or
unreaped process fails it. Verified the discriminator by gutting drain()
locally: exactly these two tests fail.
Review findings on the config catalog:
The transitive-paste closure deduplicated by bare name, so two DIFFERENT
package-local declarations sharing a name would silently collapse to the
first one pasted — and a reference satisfied by the wrong declaration is a
wrong catalog, not just a thin one. Every resolution is now identity-checked
by source pointer: reaching the same declaration again is benign, while a
name that resolves to two distinct declarations, to conflicting imports, or
to a declaration in one file and an import in another is a violation — a
verbatim fence has a single flat namespace, so the fix is a rename at the
source. A spec case pins the two-declarations collision.
docs/config-catalog.md also joins the translation-pairing exclusions: it is
a generated English-only doc, and without the entry the gate would accept a
hand-added .zh.md twin it must reject.