The exact-shape fix added two filesystem-failure catch branches that only
fire on a race/permission fault the caller already guards against (the
session-dir readdir after an isDirectory() check, and the discovered-root
rmdir after the root was observed empty). Neither is deterministically
reproducible in-process, so tag both with the same reasoned v8 ignore the
sibling catch blocks already use, restoring per-file 100% coverage and the
symmetry between the parallel rmdir handlers.
Tighten the startup sweep to backend-generated name shapes and fix the tests
that had drifted from the SweepRoot-based API:
- Match roots by the exact `dsh-spill-<6>` mkdtemp shape and session dirs by
`session-<12 hex>` (DEFAULT_ROOT_RE / SESSION_DIR_RE), replacing loose
startsWith checks so foreign or fixture-shaped directories are never swept.
- Carry `SweepRoot { path, pruneWhenEmpty }` through SweepOptions so a discovered
prior-default root is removed once emptied while the active root is never
pruned; lstat each session entry so a symlinked session dir is not followed.
- Fix the tests to the SweepRoot API: import SweepRoot, correct the gatherRoots
override return shapes, build discovery fixtures with the real mkdtemp shape,
and route the warn-wiring test through a deterministic failure path.
The local spill backend never reclaimed its files, so configured roots
grew without bound and default per-process dsh-spill-* temp roots piled
up across runs. Immediate deletion is unsafe because persisted, resumed,
and forked sessions may still reference an older locator.
Add a fiber-owned, best-effort sweep that runs once after activation
(never delaying availability, awaited on disposal): it deletes regular
files older than cleanupPeriodDays (default 30; 0 disables) across the
configured root and prior default temp roots, prunes emptied dirs, and
skips symlinks/unknown entries. Every filesystem failure is contained
and logged, so the sweep cannot fail activation or a concurrent write.
Recheck the listing cancellation signal after each child inspection settles:
a per-child read failing with a diagnostic-mapped code during an abort
previously bypassed the inspection's own checkpoints, letting a cancelled
single-child scan return a successful result.
- listChildren() checks its cancellation signal before starting the corpus
trace and immediately after it resolves, so a pre-aborted or trace-time
abort fails CANCELLED instead of returning a successful empty result.
- Corrected the persistence-repair claims: session query resolves persisted
candidates through the non-mutating inspect() read, so listing is
storage-read-only and a torn child tail is surfaced (shorter valid prefix)
rather than repaired; the Agent Note, README, and JSDoc now state that,
and the zh pair is re-recorded.
- listChildren() takes an optional AbortSignal and rechecks it after every
un-signalled session-query await (the cold-resume cooperative-cancellation
boundary); list_agents forwards exec.signal so the registry's drain of
started tool bodies cannot block on a slow or large catalog.
- The list_agents description now presents running/complete as a stored-record
snapshot and defers deliverability to send_message, matching the ownership-
conflict semantics the service tests pin.
Implements the durable-subagent-catalog RFC: SubagentControlService.listChildren()
enumerates a parent's direct continuable children from one sessionQuery trace,
validates each child's sole subagent/descriptor event (now carrying the durable
creation label), and returns one ordered SubagentListEntry[] with per-child
corrupt/unsupported/unavailable diagnostics. The list_agents tool ships as a
separately loadable plugin of dsh-tool-subagent-control requiring sessionQuery
at load; send_message stays usable without it.
Remove the host-user continuation capability and the public residency query,
then separate the seam's public event payloads from its internal lifecycle
control interfaces.
`followup()` now takes the exact live direct parent `Agent` instead of a
`SubagentAuthority` union. No production adapter ever supplied user authority,
so the `UserAuthorityGrant` brand token existed only to stop a forged
discriminant from bypassing the direct-parent check — deleting the branch
retires the token, its mint method, and that attack surface together.
Narrowing `parent` from `Agent | undefined` to `Agent` removes three special
cases, including the path where a parentless epoch dispatched its lifecycle
events unscoped. Scoped-versus-global dispatch is now decided by the event, not
by whether a caller happened to have a parent.
`activationState()` had no caller; `ActivationState`, `ActivationObserver`, and
`ContinuationHost` are package-private.
New `src/lifecycle.ts` owns the contained emitter, the one-shot run observer,
and the Activation observer, while `SubagentRunInfo`/`SubagentRunEndInfo` move
to `src/types.ts` beside the other consumer-facing contracts. Those payloads are
public API — dsh-jsonrpc, hooks-claude, and the package invariant all consume
them — whereas the observer is a contract between two in-package collaborators,
so they no longer share a home merely for both being lifecycle-shaped. The
service keeps ownership of the scope carrier: `scopeTarget()` composes the
service's own context filter, so a narrowed stand-in would silently change
scope filtering.
Also drops now-unused dsh-tasks-local and dsh-tool-tasks dev dependencies, and
corrects the README claim that a pre-residency failure emits a terminal edge —
that path only ever rethrew.
- Make host-user authority unforgeable. `{ kind: 'user' }` was a bare
discriminant, so any plugin holding `ctx.subagents` — including
model-generated cordis_mount code, which the advanced ACP composition ships
alongside continuable subagents — could construct it and skip the
direct-parent check for any known child id. It now carries an opaque grant
that only SubagentService.userAuthority() mints, which composition hands to
trusted host adapters; a model-facing tool uses parent authority from its own
execution context.
- Reconcile a delivery discarded inside its own admission window. An enqueue
listener that cancels fires the discard before followup() returns, so the
discard listener could not clear an id it had not seen; submit() retained it
and residency stayed `running` until an explicit drain.
- Recheck the caller signal after materialization. An abort landing between
publication and inbox acceptance still submitted the prompt and returned both
ids; it now rolls the child back.
- Stop promising the model transcript access that no shipped continuable config
mounts. The tools now state only that a background child does not report back.
- Restate the implemented note as shipped state rather than a proposal, so it
works as current authority.
Round 1 traded one teardown ordering problem for another. The observer now
splits capture from emission, which satisfies both consumers at once:
- Terminal facts are captured while the child is still registered, so consumers
that resolve it for the child's log and scope still work.
- The edge is emitted only after handle disposal settles, so a rejecting scoped
cleanup is reported as a failed epoch instead of a successful one.
Also:
- Keep the Activation in the map until disposal settles. Removing it first let a
racing followup() see no Activation and cold-resume into the still-registered
agent, and let a concurrent forest drain skip a still-disposing child and
release its parent first.
- Derive terminal telemetry from this epoch's event suffix rather than the whole
session, so a cold resume whose prompt is blocked no longer reports the
previous epoch's answer and turn reason.
- Cancel the ACP bridge's own prompts before awaiting the descendant drain: a
drain can block on persistence, and the top-level agents must not keep running
model and tool work for its whole duration.