Out-of-process delegation to external coding agents as two new subagent
seam backends, exposed as subagent_claude_code / subagent_codex tools.
Verified against @anthropic-ai/claude-agent-sdk 0.3.202 and codex CLI
0.142.5 via keyless spikes; includes the dsh-subagent-process extraction
plan, isolation/permission stances, and tiered test coverage.
A folded mode name the current config no longer defines behaves as default
plus one boundary notice naming the dropped definition — set()'s loud
validation covers only the write path.
The #192 review discussion, preserved where design rationale lives: a
schema-object library (zod/schemastery) cannot sit at a wire-data boundary;
ajv replaces only the value walker while the subset gate — the module's
point — stays hand-written; provider JSON mode guarantees valid JSON, not
schema-conforming JSON, and would trade away mid-run tools and in-turn
validation retry for it. Strict tool schemas are named as the accepted
upgrade path when the provider ships them.
The deliverable is plan mode — retitle and reorder the proposal opener so
the feature leads and the logged session-mode state is its implementation
property. Fix the Codex citation in the boolean-planMode alternative: Codex
has plan mode (/plan); its ACP mode list carries its approval presets — two
different surfaces. Durable vocabulary (mode/set, ctx.modes, dsh-mode) stays
string-shaped so a second mode never renames logged event types.
The zh.md counterpart (section-for-section mirror per the i18n contract:
identical heading structure, byte-identical text fences, same link targets)
plus the recorded i18n.yaml and the language-switcher lines on both sides —
matching the approval / sandbox / env-state RFC practice.
Re-audited against origin: master unmoved; approval seam still on
feat/sandbox-support; three relevant branches appeared since drafting.
- env-state-visibility RFC (fold-from-log + boundary application for env
facts): adopt its boundary-narration principle for user-driven mode flips
(one coalesced notice when the flushed mode differs from the last request
header's fold; net-zero silent; tool exits self-narrate via result), and
resolve the one real collision — its config-phase sketch routes ACP
session/set_mode to env facts while this RFC claims the picker. Proposed
division: picker-to-modes / knobs-to-config-options; a mode definition may
later bundle env facts; second lander amends. Also note the source-field
contrast (env drift has no log-adjacent cause; every mode/set does).
- sandbox-escalation: first live approval composition — named as the
precedent our recorded scenarios follow.
- ask-user-question / user-interaction seam: the stdio approval answerer
rides its one-prompt-owns-stdin queue where mounted.
- Config: pin the allowlist as the degenerate form of a per-tool
allow|deny|ask map so execution-phase ask policies arrive without a
config migration (deferred on approval's allow_always).
- Dedup the ACP mapping (one home: Protocol and UI surfaces).
Adds the Detailed design block: the mode/set vocabulary (no provenance
field — cause is log-adjacent; bare-string mode names), ModeConfig with an
explicit resolve step, the cursor-cached fold + ctx.modes service with
turn/start|step/end pending flush, the computed mode:policy section (order
50) + post-next() assembly filter (exit_plan_mode visible IFF plan mode —
what keeps default-mode assemblies byte-identical), the fold-only gate with
its ask on exit_plan_mode (no dsh-approval dependency — the registry
routes), the exit tool contract and render intent, dependency edges and the
stdio/ACP surfaces, the input.json setMode op + plan-mode/plan-mode-reject
scenarios (mode set before turn 1 → the initial header snapshot is already
in plan shape; the widening delta appears at exit), and the mechanical tail.
Consistency fixes: the plan allowlist names real tools (no grep tool
exists); the re-widen risk now states the post-next() mechanism instead of
a listener-order convention.
A session mode is a named, logged, per-agent policy state: mode/set as a
log-only SessionEventMap member (todo/write shape), a foldMode over the log,
soft enforcement at system-prompt/assemble (filtered schemas + mode section,
auto-logged as request/header-delta), hard enforcement at tools/pre-execute
(deny-by-default against the mode allowlist), and a thin ctx.modes service
with turn-boundary pending-intent flush for user flips. One new product
package dsh-mode (packages/mode/mode, the approval-group shape); plan is the
only shipped definition.
Lands as ONE feature — a plan mode without a model-driven, approvable exit is
not a smaller version of the feature. The roadmap's two stages are build-and-
review order for a single stacked landing: the mode core, then exit_plan_mode
(ask-gated through the approval seam; the plan text rides the tool args as
the reviewable log artifact), the stdio readline answerer, and the ACP
session-mode surface (session/set_mode, current_mode_update, available
modes). Hard prerequisite: the approval seam lands first; the stack bases on
feat/sandbox-support meanwhile.
Master's generated config catalog (#188, flattened paths #191) now records
plugin Configs; the nudge removal dropped structuredNudgeRetries from both
backends, so the regenerated catalog loses those rows.
Ship @deepseek-ai/dsh-retention under packages/util/: pure ItemRetainer /
TextRetainer plus neutral notice helpers, so tools that cap model-facing
output share one "what did we keep, what did we omit, may we stop reading"
mechanic while keeping grouping, exit codes, provider errors, and recovery
prose tool-owned. The two retainers are separate names because they differ in
resource model: item-head can stop the upstream on the first over-cap probe
(shouldStop), while text tail/head-tail must read to the end. The library
documents glob/grep/bash/web_fetch/web_search mappings but migrates no tool
yet — glob/grep don't exist, and migration is deliberately separate work.
Flips the RFC to implemented/ and rewrites its skeleton to shipped reality.
The parent branch dropped structuredNudgeRetries; the two workflow-side test
setups stop passing it, and the RFC's foundation paragraph now describes the
current design (final-ASSEMBLY enforcement logged via request/header, the
post-capture pre-execute deny, the start() schema snapshot, no re-prompt)
instead of the retired agent/request + nudge shape.
Residuals from the Codex re-review:
1. A system delta's insert was flattened to one token, so deltas differing
only in inserted-line count compared equal. Now one {{system}} token per
inserted line — position AND extent survive, content does not.
2. The live uniformity guard folded only request/header snapshots, so a
mid-run header CHANGE (request/header-delta) could diverge from the pin
invisibly. Non-pinning runs now assert zero header-delta events: a
scenario that legitimately changes its header mid-run exists to show
that change, so it must pin (fail-loud until it does).
Codex review findings on the pinned-header change:
1. scrubRequestHeaders flattened a request/header-delta's whole
system/tools payload to one token, so two meaningfully different deltas
compared equal. Now the structural facts survive — keepStart/keepEnd
line positions, added/removed/changed tool NAMES — and only the bulk
(inserted prompt lines, schema bodies) is tokenized.
2. The one-pin design rested on an unasserted premise (all sessions
compose the same header). Every non-pinning scenario now asserts, live,
that each request/header its run produces equals the pinned fixture's
header (both sides normalized against their own volatile values), so a
session-dependent header fails loud until it gets its own pin.
Verified the guard bites: perturbing the pinned fixture's prompt fails
a non-pinned scenario with the intended message.
3. RFC de-slopped per docs/AGENTS.md: no PR reference, no SHOULD
spec-speak; Decision/Verification/Consequences updated for 1 and 2.
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.
Codex round-2 review found three adjacent fail-open shapes:
- Wrapped function expressions escaped classification: parentheses,
as/satisfies casts, and non-null assertions are now peeled before the
arrow/function test (initializers and default exports), and a
single-call-signature type literal counts as the surface signature.
A literal mixing call/construct signatures with anything else is
refused outright — no single signature to hold the tags against.
- The blanket 'export import X = N.member' skip was unsound (the target
can be a non-exported namespace member no walk visits): an alias now
documents itself.
- The heritage extra-parameter duty missed binding-pattern extras,
which no base declaration can name: they now trigger the standard
binding-pattern violation.
Five new negative-path tests pin the closed shapes; module doc and RFC
updated.
Every session.jsonl fixture embedded the full composed system prompt and
complete tool-schema list in its request/header event (~8 KB on one line,
identical across the suite), so any prompt or tool-schema edit forced a
re-record or hand-edit of every fixture — see the dynamic-workflows PR for
the churn pattern this removes.
Now exactly one scenario (text-turn, flagged pinsHeader) commits and
compares that content verbatim; every other fixture stores and compares it
as {{system}}/{{tools}} tokens via the new pure scrubRequestHeaders
normalizer (applied to both compare sides and to record-mode writes, so a
re-record cannot reintroduce the content). request/header-delta payloads
are scrubbed the same way; config/reason stay verbatim — a model swap
SHOULD churn every fixture, a prompt edit should not. Replay is unaffected:
script derivation reads only assistant/chunk events.
Fixture meta-guards enforce the split: non-pinning fixtures must be fixed
points of the scrub, the pinning fixture must not be, and exactly one
scenario pins. Committed fixtures migrated through the same function.
Docs: pinned-header RFC (implemented/testing), base snapshot RFC + testing
policy + llm-replay module doc/README updated.
Carved out of #170 per review feedback — the foundation the workflow tool
builds on, now standing alone on master:
- dsh-tools: the structured-output JSON Schema subset (StructuredOutputSchema,
assertSupportedOutputSchema, validateStructuredValue) — rejects loud outside
the enforced subset, listing every violation
- dsh-subagent: SubagentStartRequest.outputSchema / SubagentResult.structured
become a real capability; the service rejects a schema'd request whose
provider lacks it
- dsh-subagent-inprocess: the shared structured runtime — one global
structured_output capture tool, a prepend final-assembly listener that
strips the placeholder for plain agents and swaps in the run's own schema
(plus the calling instruction as a trailing section) for structured
children, an agent/turn-continuation veto once captured, and the
capture/nudge loop in the run driver (structuredNudgeRetries, cancellation
honored mid-nudge); lifetime refcounted by backends and live runs
- subagent-spawn / subagent-fork flip outputSchema: true
One deliberate divergence from the #170 revision: the backends do NOT add
'tools' to their plugin inject. Doing so deferred their apply past the todo
plugin, and the delegation tool mirrors provider lifecycle — so the
model-visible tool order of every existing prompt changed, invalidating every
recorded snapshot fixture. The runtime now gates its capture-tool registration
on tools availability itself (sync when live, a scoped inject fiber when the
Loader starts the backend first), keeping this PR byte-invisible to existing
transcripts: all 35 snapshot scenarios pass against master's fixtures
unchanged.
Codex round-1 review found three fail-open paths in the new gate:
- Unhandled export forms passed silently. checkDecl now fails CLOSED on
unrecognized exported statement kinds, 'export =' is refused outright,
'export import X = N.member' is an explicit documented skip (alias;
definition site owns the doc), and ambient 'declare namespace' bodies
recurse with implicit export semantics.
- Function-like exports escaped the function contract: non-identifier
default exports and consts with INLINE function-type annotations now
get full @param/@returns checks (the named-type waiver stays for
reference annotations only).
- The heritage exemption was name-only: it no longer exempts a public
override of a protected-only base member, and parameters the base
never names keep their @param duty (underscore-prefixed renames of a
base parameter count as the same parameter).
Eight new negative-path tests pin the closed gaps; RFC and module doc
updated to the refined contract.
Review findings on the config catalog:
The schema-subset check compared only top-level z.object keys against
top-level type members, so a nested loader-accepted key (agents[].id,
agentOptions.model, capabilities.*) missing from the declared type would
pass the gate unseen. The walk now collects nested object/array
compositions as key paths and resolves each against the declared config
type — through interfaces (heritage included), aliases, literals,
intersections, unions, arrays, indexed access, Partial-style wrappers, and
type references across package-local and workspace imports (re-export
chains included). The check stays presence-only and one-directional, and
only a definite miss is a violation: a path crossing a type the walk
cannot enumerate (an external package's) is skipped, never mis-reported.
The recursion guard applies at named declarations only — a structural
first child shares its span start with its parent, so a span-keyed guard
on every node mistakes ordinary descent for a cycle and silently turns
definite misses into unknowns.
The page and RFC framing also overstated the catalog as the exact
cordis.yml-settable surface: the paste is the plugin's full declared
config type, and a field the runtime schema deliberately excludes (the ACP
bridge's test-injected stream) is a runtime-only seam its own JSDoc marks.
Both now say so.
Five spec cases pin the new behavior: nested hidden key, workspace
intersection via star re-export, Partial wrapper, indexed-access
composition, and the external-type unknown path.
Master's reconstructable-requests overhaul (#179) meets the workflow tool:
- subagent-inprocess structured-output nudge becomes a system-prompt section
plus logged context (the injected-request waterfall shape is gone upstream)
- snapshot fixtures re-recorded on the merged tree so every request/header
carries the workflow tool; authored error-finish/cancel headers patched to
the merged tool list and system text
- architecture.md condensed back under its word ceiling; module graph regenerated
A directory holding exactly one file adds a path level for nothing;
cordis-catalog/ keeps its folder because it holds two sibling pages
(events.md + services.md), matching config-catalog.md which was born flat.
docs/tool-catalog/tools.md -> docs/tool-catalog.md and
docs/persistence-catalog/log-events.md -> docs/persistence-catalog.md; their
generators' OUT paths and in-page relative links drop one directory level,
and every citation repo-wide (docs, RFCs, package READMEs, generator
headers) is updated. graph-atlas.md, config-catalog.md, and the doc graphs
regenerate with the new paths.
New doc-sync gate verify-export-jsdoc walks every module-level exported
name under packages/*/*/src and requires description prose everywhere,
plus @param per parameter and @returns on non-void annotated returns for
function-like exports, public class methods, properties, and accessors.
The parsing + check helpers move out of gen-cordis-catalog.ts into a
shared scripts/jsdoc.ts so 'documented' means one thing on both gated
surfaces.
Deliberate exemptions (documented in the RFC): heritage-declared class
members (the seam declaration is the doc's one home — the one checker
query in an otherwise pure-AST walk), cordis plugin-protocol slots
(name/inject/reusable/Config/apply, top-level and static), constructors,
overload implementations, declare-module augmentation bodies, and
re-export statements (checked at the defining module).
The 203 under-documented exports the gate found at adoption are filled
in this change, so the gate lands green; generated catalogs/graphs are
regenerated for the shifted line pointers.
RFC: docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md