Every packages/*/* README now carries a canonical '## Known Limitations and
Deferred Work' section: condensed, evidence-backed bullets for consumer-visible
gaps (unimplemented features, platform caveats, MVP cuts) and consciously
postponed work (TODO/FIXME/XXX markers, RFC deferrals still open). The ten
pre-existing ad-hoc variants ('What is NOT here (TODO)', 'Deferred',
'Limitations (MVP)', 'Known limitations (tracked TODOs)', ...) are normalized
into the canonical heading.
A new doc-sync gate, scripts/verify-readme-limitations.ts, enforces the shape:
exactly one limitations-like heading per package README, byte-equal to the
canonical h2, with at least one bullet; near-miss headings fail so variants
cannot creep back. Packages with genuinely nothing to declare (dsh-brand,
dsh-timeout, dsh-subagent-mock, dsh-app-boot) are whitelisted in the script and
must NOT carry the section; whitelist entries are validated against the scanned
package set so a rename fails loud.
Wired into the doc-sync chain (package.json) and the run-gates doc-sync leaf
set; the standing rule lands in packages/AGENTS.md and the adding-a-package
cookbook; decision record in
docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.md
(RFC index regenerated).
Also fixes two stale '(deferred)' markers claiming dsh-compact-basic is
unimplemented (the dsh-compact seam README's package table and the seam's
module doc comment).
dsh-agent
Agent interface, registry, and agent/* event vocabulary. Every plugin (UI, hooks, orchestrators) programs against the Agent handle defined here — it has zero loop dependency, so the loop is swappable.
Service: AgentRegistry (ctx key: agents)
Tracks live agents so UI, hook, and orchestrator plugins can find them without importing the concrete loop package.
Public API
The scoped-registration surface: Agent.ctx is the agent's scope context (dsh-scope, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. agentEvents(ctx, agent) is the fused dispatcher every agent-subject event goes through (carrier + injected subject in one move); assembleContextFor(agent) builds the per-agent assembly context (agent + scope together). CreateAgentOptions.setup(agentCtx) and ResumeAgentOptions.setup(agentCtx) compose a fresh or resumed agent's scoped world while the factory keeps the agent and session unpublished; creation awaits setup and a same-turn owner-unload checkpoint before either creation notification or the first assembly. Setup composes, it never drives: the concrete loop rejects driving verbs until the agent/session-start boundary.
ctx.agents.register(agent: Agent): () => Promise<void> | void— record an already-constructed agent. Disposed with the calling fiber.- Advanced ordered lifecycle:
enter(agent): () => voidinserts without announcing, andannounce(agent)emitsagent/createdonly for that exact live entry. The async factory uses this split after setup; ordinary plugins useregister(). ctx.agents.get(id: AgentId): Agent | undefinedctx.agents.list(): Agent[]
Factory seam (creation)
Agent creation is provided by the plugin implementing AgentFactory (dsh-agent-loop), registered via setFactory. This keeps creation on the dsh-agent interface so consumers (UI, the ACP bridge) program against ctx.agents without depending on the concrete loop package.
ctx.agents.setFactory(factory: AgentFactory): () => Promise<void> | void— register the creation factory (the loop calls this on construction). Throws on a second factory; the slot clears on dispose.ctx.agents.create(options: CreateAgentOptions): Promise<AgentHandle>— snapshot caller-owned IDs/options/metadata/seed, construct and await optional setup while unpublished, insert and announce both session and agent, open theagent/session-startdriving boundary, then start a new loop on the caller-suppliedsessionId. Agent/session IDs are reserved across setup; setup rejection or owner unload publishes nothing. Publication is rollback-covered: if a creation listener throws, entries and scope unwind but effects of already-delivered notifications remain observable; an agent whose announcement began emitsagent/disposedduring that rollback. Rejects if no factory is registered.ctx.agents.resume(options: ResumeAgentOptions): Promise<AgentHandle>— snapshot caller-owned IDs/options, load a persisted session (session persistence), mint a fresh agent scope, await optional setup while unpublished, then follow the same insert → announce → session-start → loop-start boundary. The IDs are reserved across persistence load and setup; load/setup rejection or owner unload publishes nothing. Rejects if no factory is registered or session persistence is unconfigured.
AgentHandle = { agent: Agent; dispose(): Promise<void> }. The disposer is a capability — only the holder can tear this agent down. dispose() stops the loop, awaits its exit plus every outstanding idle-injection flush (quiescence — NOT just the disposed status flip), unregisters the agent, removes its session from the store, and finally unwinds its scoped world. This order captures every agent-started session/flush before the session is detached and keeps scoped listeners alive through those checkpoints. ctx.agents.get(id) still returns a bare Agent — the handle is only for the OWNER that created it. The ACP bridge and in-process subagent backends are production consumers; config-created agents are owned by the loop fiber and never need a handle.
Live events
dsh-agent declares the live agent/* coordination vocabulary so plugins do not depend on the concrete loop. Exact signatures, dispatch modes, scope-filtering rules, and payload contracts live in the generated Cordis event catalog; the architecture turn flow shows their order relative to durable session events.
The lifecycle edges have two important local caveats. agent/created runs after scoped setup and after both session and agent registry entries exist, but concrete driving remains locked until the immediately following agent/session-start; that non-vetoing notification is the first supported startup injection point. agent/disposed runs after the driver is quiescent and the agent leaves the registry, while ordered teardown may still be detaching its session and unwinding its scope.
Most interception points are cooperative waterfalls returning seam-specific decisions. agent/pre-step is a serial surface-mutation checkpoint, while agent/turn-stop is the owner-final exception: it runs after ordinary continuation and steering folding, and its terminal state remains through turn close and flush so steering from those later listeners cannot create an extra step or turn. Ordinary queued prompts remain intact. The full rationale is in the agent-scope RFC.
Turn and step boundaries and the model token stream are durable session/event facts rather than mirrored agent/* notifications. Consumers read turn/*, step/*, and assistant/chunk from the session feed; tool policy and outcome observation belong to the complete pipeline documented by dsh-tools.
Agent interface (types.ts)
The handle every plugin programs against:
agent.send(content, options?)— queue a message; starts a turn when idleagent.steer(content, options?)— steer a running turn (inject between steps); behaves likesendwhen idleagent.inject(content, options?)— inject in-session context (context/message event); the next request sees it. Does not run the model. While a turn is open it joins that turn; while idle it is wrapped in a one-shotinjectionturn so every event stays turn-enclosed (the turn-enclosure invariant)agent.cancel(reason?)— cancel ALL pending work: clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs. A UI/ACPsession/cancelmaps to this. The single public stop primitive. Idle with nothing pending → a safe no-op.agent.whenIdle()— resolve once the agent reaches quiescence after settling out ofrunning(idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters viaAgentHandle.dispose(), which awaits the loop exit directly.agent.session,agent.status,agent.options,agent.id
Extension points
- Agent creation:
AgentLoop.create()is the concrete config-path implementation (indsh-agent-loop), while programmatic consumers create/resume owned agents throughctx.agents.create()/ctx.agents.resume(). Replace the loop by implementingAgentand registering viactx.agents.register(). - Event listeners: all
agent/*events are declared here — no dependency on the loop package needed. - Subagent delegation: implemented by
@deepseek-ai/dsh-subagent, not by a method onAgent; providers create or drive ordinaryAgenthandles through the factory seam, so spawn/fork/ACP transports stay outside the core agent interface.
Known Limitations and Deferred Work
- Inter-agent channels beyond delegation — shared state, streaming child output, and background/poll semantics remain outside the current synchronous
ctx.subagentsseam. agent/session-startcannot gate startup — a synchronous, veto-less emit, so an async listener's injection is best-effort before turn 1; startup gating is a deferred loop-level change.- No public step-only abort —
cancel()clears ALL pending work (queued + steering + in-flight); an abort that preserves queued prompts returns only with a named consumer (stop-surface RFC). AgentRegistryenforces unique agent ids, not session ids — two live agents sharing a session id mis-route bash owner-token notices; id unification stays proposed.HookContextcarries exactly oneMessageSource— contributions from several plugins merged onto one tool call collapse under one source; mixed provenance is unrepresentable.SessionStartSourcereserves'clear'/'compact'with no emitter yet — only'startup'/'resume'occur until the driving subsystems land (TODO(compaction)).agent/pre-step'sfullSystemPrompt/sessionPrefixparameters are a flagged smell — compaction is their only consumer; a lazy prompt provider or a compaction-specific pressure seam is the marked revisit.