The tree SHAPE was the wrong surface for the model: what it needs from
cordis_inspect is what services, plugins, and capabilities are loaded, not the
fiber hierarchy. The plugins section is now a flat name + lifecycle-state list
from ctx.registry (deterministically sorted, one line per instance); the ASCII
tree renderer, the parent→child rebuild, and the dyn-id tree annotation are
deleted — dynamic mounts keep their own richer dynamic section (id, state,
provides, waits). Net -49 lines; RFC and READMEs state the flat-list contract.
Field sessions showed models writing tool schemas in the JSON-Schema dialect
by strong prior — type: 'integer', required: false, then the full
{ type:'object', properties, required: [...] } wrapper — and the rejection
text itself pushed a nearly-correct DSL attempt BACK to raw JSON Schema: one
stats tool cost three consecutive schema errors before mounting. The boundary
now normalizes wherever the input has exactly one meaning (wrapper unwrapped
with the required array becoming per-property flags at any nesting level,
integer → number, required: false → optional, all rebuilt as fresh host-realm
objects) and rejects only genuinely meaningless input, enumerating the valid
vocabulary in the error. Re-running the failing session mounts first-try.
The mount description documents both accepted forms.
The sandbox deliberately provides no Node API, and now says so instead of
letting a bare ReferenceError teach nothing: require, the timers, and fetch
are callable traps whose error redirects to the cordis alternative (inject:
['fs'] + ctx.fs, ['web'] + ctx.web, ['bash'] + ctx.bash, ['timer'] +
ctx.setTimeout — a fiber effect, unwound on unmount). Only function-shaped
globals are trapped; process/Buffer stay undefined so typeof feature probes
stay inert. The mount description and the demo persona state the routing rule,
and the demo mounts ctx.fs (local provider) and ctx.web (seam + keyless local
fetch provider) so agent-built plugins have real capabilities to build on.
Live-validated: a model that reached for Node setTimeout self-corrected to
inject: ['timer'] in one step and built a working ctx.web fetch tool.
Everything outside the package and example that a new top-level group and a
new demo touch: GROUP_ORDER in gen-module-graph and gen-doc-graphs (plus the
tools-service consumers list, the APP_EXAMPLES entry, and the graph-atlas
label/mode rows), the knip e2e entries, the packages/README group row, the
AGENTS.md layout and demo lines, and the regenerated module-graph /
config-catalog / graph-atlas / capability-seams / composition artifacts.
AGENTS.md and examples/AGENTS.md word-budget ceilings rise to current+5%
(1802 / 653): the new group and demo rows are genuine additions to both docs,
not condensable restatements.
Emits packages/cordis/tool-cordis/src/api-catalog.ts (the data cordis_inspect
serves the model) from the same JSDoc-enforcing AST walk as docs/cordis-catalog
(collectServices/collectEvents, plus the now-exported INHERITED_SERVICES
table): service summaries + method signatures, event modes + signatures, and
the transitive closure of type shapes the signatures reference — so a mounted
plugin reads that a bash run's stdout is { text, truncated } instead of
guessing. verify-cordis-api joins doc-sync as the freshness gate.
The coding spine (DeepSeek V4 + local bash on dsh-stdio-agent) plus
@deepseek-ai/dsh-tool-cordis loaded by package name, run via demo:cordis.
Ships the keyless Loader smoke (the export-shape / package-name-resolution
guard) and the with-key smoke: a real model mounts a listener whose tagged
console line actually fires, builds and calls its own reverse_text tool, and
composes two mounts via provide/inject — all world-verified against the
registry and session events.
New top-level packages/cordis/ group with the self-referential toolset:
cordis_inspect (services / plugin tree / tools / dynamic mounts / api / events,
the api section intersecting the generated catalog with the live service store),
cordis_mount (model-written code evaluated in a node:vm sandbox, mounted under
one cordis-dynamic group fiber as dyn-<n>), cordis_unmount (awaited disposal to
quiescence). Boundary mechanisms: dual-realm instanceof, JSON realm
normalization of dynamic tool results, marker-guarded registration, SchemaSpec
teaching errors, parse failures surfaced with the offending line + caret and a
line-scoped TypeScript hint, and the unmount-first recipe on tool-name
collisions. Config: vmTimeoutMs (schemastery, default 5000). Design record:
docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md.
The tool-catalog boot manifest, its regenerated output, and the pinned
tool-name list land here rather than with the other repo registration: the
completeness guard globs packages/*/tool-* and fails the generator (and the
core/tools spec) the moment the package directory exists.
The design record for tool-cordis: the three-tool contract, the vm sandbox
trust stance and boundary mechanisms, the dynamic-group lifecycle, cross-mount
provide/inject composition, the generated runtime API catalog, and the
alternatives weighed (per-capability registration tools, hand-maintained API
tables, a mount provenance event, a hardened sandbox).
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 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.