The extracted library's name sat one edit away from @deepseek-ai/dsh-subagent-inprocess
(process/inprocess), inviting a typo'd import to silently resolve to the wrong
package. subagent-subprocess also reads as the deliberate counterpart to
subagent-inprocess (in-process vs. subprocess), matching how the two shared
drivers actually differ.
Package directory, npm name, module doc, JSDoc module tags, test-file name and
its temp-dir prefixes, the subagent-acp import and its Config/tsconfig/package.json
references, root tsconfig.json/tsconfig.build.json/knip.json entries, and the
packages/subagent group README all renamed together; regenerated
docs/module-graph.md and docs/config-catalog.md. Pure rename — no behavior,
export, or Config shape changed.
The scrub pattern and layering semantics live in the dsh-subagent-process
README (the fact's home since the extraction); the ACP section restated them
in full — two prose copies drift word by word until they disagree (the
one-home-per-fact rule in docs/AGENTS.md). The section now links the library
and keeps only the backend's own story: which credential enters via
config.env and why.
Each timed-out wait used to leave the once('exit') listener from its inner
waitForExit attached to the child; the dispose ladder accumulates at most a
couple, but in a shared library a caller polling exitsWithin in a loop would
pile listeners onto one child (MaxListenersExceededWarning at 11) and retain
their closures. The race now owns its wiring: the timeout arm removes the
exit listener, the exit arm clears the (still unref'ed) grace timer, and an
already-exited child short-circuits true without attaching anything. Tests
pin listenerCount('exit') === 0 after every outcome.
The best-effort-remove test provoked a real EACCES via a chmod-000 subtree,
which only fails for unprivileged users — under root, recursive rm ignores
permission bits, deleting the subtree: the existsSync assertion goes red and
the swallow branch loses coverage, failing the per-file gate. The rejection
is now injected deterministically at the node:fs/promises boundary (rm
wrapped with a real-passthrough vi.fn; one test queues a single rejection),
the fs-failure boundary being exactly the non-deterministic seam the testing
policy sanctions mocking. Everything else in the suite stays on the real
filesystem, and the swallow contract stays error-kind agnostic.
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.
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.
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
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.
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.
A tool declares its cooperative timeout budget on its own definition
rather than a deployment naming it in a central config map. The field
never reaches the model (schemas() whitelists name/description/parameters)
and defineTool rejects a non-positive-finite value at authorship.
Two findings from the GitHub review bot on the ready PR:
The tsdown two-entry build emitted the shared bootstrap module as a
lib/bootstrap-*.js chunk imported by both bundles, which the package.json
files whitelist (deliberately exact) omitted — a packed install had
dangling imports. The package now runs two single-entry builds, so each
bundle inlines its own bootstrap copy and every shipped file is
self-contained.
prepareValue admitted any cloneable value whose BOUNDED inspect rendering
fit maxValueBytes, so a huge container with a compact rendering (a
50k-element array renders as '... N more items') crossed the port raw,
bypassing the cap on both sides. The cap now measures the value's real
cross-boundary size — exact bytes for strings, the structured-clone wire
size (v8.serialize) for everything else — and oversized containers cross
as their bounded rendering instead.
ds-review-bot flagged that a typo'd or stale config key (e.g. web_fech for
web_fetch) silently applies the timeout to nothing — the tools/execute lookup
just never matches. Mirror dsh-tool-subagent's lifecycle-driven handling of a
configured-but-unregistered provider: on every tools/change (and once at load),
logger.warn each configured name still absent from ctx.tools, warning each name
at most once so a late registration silences it. Not a load-time throw — the
tool set is dynamic (cordis.yml load order, HMR), so a real tool may register
later.
Declare inject = ['tools'] since the plugin now reads ctx.tools synchronously
in apply (previously only inside event callbacks). Regenerate config-catalog
(Requires: tools) and event-producer-consumer graph.
The host's message listener trusted the compile-time WorkerToHost shape on
traffic from a peer that runs model code: postMessage(null) threw in the
listener and crashed the host process; forged log/done messages bypassed
maxLogBytes/maxValueBytes (the worker-side LogBuffer and prepareValue cap
only honest flows); and the error-reply renegotiation re-echoed a forged
non-cloneable call id, throwing outside any catch.
Every inbound message now passes a runtime shape gate that validates and
REBUILDS it field by field (junk drops without a throw; call ids must be
numbers, so replies are always clone-plain; forged extra fields never ride
along). One host-side ledger bounds everything landing in logs — honest
port entries, forged ones, and stray pipe bytes — at the single documented
maxLogBytes, with the shared in-band truncation marker emitted host-side
when the ledger trips first; the completion value is re-capped host-side
through the same prepareValue (with exactly the truncation suffix as slack
so honest worker-capped values pass unchanged), and done error text is
bounded. Also folds the stray-capture budget into that shared ledger
(round-1 finding B: it was a second maxLogBytes on top of the documented
shared cap).
The shipped backend of the code-execution seam, per the Code Mode RFC's
worker-thread section: one fresh Node worker per run, executing the
model's TypeScript after a host-side type-strip (wrapped in an
async-function shell so top-level return/await parse, sliced back out
position-preserved), bindings bridged over the message port under
hostile-peer rules (own-property name lookup, at-most-once replies,
post-settlement drops, null-prototype namespaces), logs streamed eagerly
with an in-band truncation marker, and two independent budgets — measured
event-loop busy time (computeMs) plus a never-pausing wall ceiling
(maxWallMs) — funneling into worker.terminate(). env: {} and execArgv: []
keep the isolate hermetic; disposal aborts in-flight runs and awaits
worker exits.
The worker entry loads unbuilt via Node's native type stripping
(src/worker.ts, erasable-only) and ships built as a sibling tsdown bundle
(lib/worker.js); tests/built-lib.e2e.ts pins the built load path under
plain node and joins the built-artifact smoke gate. Unit suites cover the
bootstrap in-process (fake port) and the runtime over real workers,
per-file 100%.
A single stable required check that needs every other job in ci.yml, so
branch protection no longer enumerates matrix leg names that change as
lanes and node versions evolve. if: always() keeps the job running when
a dependency fails (a skipped required check would count as passing);
any non-success result — failure, cancelled, or skipped — fails it.