- run-gates.ts docSyncLeafGates() gains verify-export-jsdoc — CI lanes and the pre-push hook execute this leaf list, not the doc-sync npm script, so the gate was previously unenforced there (proven by SessionForkErrorCode landing undocumented via a master merge while checks stayed green; now documented). Same wiring gap fixed for master's verify-config-catalog, which was also missing from the list. - The heritage exemption now recovers the base's return surface: a void base return carried no @returns duty, so an override returning a concrete result documents it itself (annotated overrides run the standard check; unannotated ones are classified by the checker so faithful void overrides need no boilerplate annotation). Three new negative-path tests pin it; RFC and module doc updated. - AGENTS.md states each principle inline instead of citing RFCs (eight citations removed; high-level doc links kept) and the editing section now carries the self-containedness rule. - Generated catalogs/graphs regenerated for the shifted line pointers.
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
ctx.agents.register(agent: Agent): () => void— record an already-constructed agent. Disposed with the calling fiber.ctx.agents.get(id: AgentId): Agent | undefinedctx.agents.list(): Agent[]
Factory seam (creation)
Agent creation is provided by whichever plugin implements AgentFactory (phase 1: 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): () => 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): AgentHandle— construct, start, AND register a new agent on a caller-suppliedsessionId(with optionalmeta.cwd/meta.parentSession/meta.seedLengthand optionalseedevents for forked children). Distinct fromregister(which only records). Throws if no factory is registered.ctx.agents.resume(options: ResumeAgentOptions): Promise<AgentHandle>— load a persisted session (session persistence) and resume an agent on it. Async; rejects if no factory is registered, or if the factory finds session persistence 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 (quiescence — NOT just the disposed status flip), unregisters the agent, and removes its session from the store, in an order that captures the loop's final session/flush before the session is detached. 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.
Events
The full agent/* event taxonomy is declared via declaration merging in dsh-agent (not dsh-agent-loop), so plugins depend only on this package.
Lifecycle (emit)
agent/created,agent/disposed— registration/deregistrationagent/status— idle / running / disposed transitionagent/queued— message entered inbox (source-resolved, steering flag)agent/session-start— the session lifecycle began (once, before turn 1), carrying aSessionStartSource(startupfor a fresh or forked create,resumefor a reloaded persisted session;clear/compactreserved). A pure notification — it cannot block startup; a listener seeds context viaagent.inject()(acontext/messagethe first request sees).
Boundaries are durable session events, not agent/* emits
Turn and step boundaries are NOT mirrored as agent/* emits: a consumer that needs them reads the durable turn/start/turn/end/step/start/step/end events off the session/event feed (the session log is the live boundary feed, carrying the Session — the turn/step numbers and reasons ride on the event data). See the event-domain-semantics RFC and the remove-boundary-mirror-events RFC.
Interception seams
agent/pre-step is a serial surface-mutation checkpoint; the rest are waterfalls that return a small, seam-specific typed Decision union (the unified idiom across the taxonomy — a CC/Codex bridge maps its permissionDecision/decision/continue fields onto these, a native plugin returns them directly):
agent/session-start(emit) — fired once before the first turn; a listener seeds context viaagent.inject()(it cannot veto startup).agent/prompt-submit— decide what happens to one drained queued message before it becomes auser/message:PromptDecision=allow(optionally rewriting the promptcontentor attachingadditionalContext) orblock(drop it; a batch whose every prompt is blocked opens a zero-step turn that endsrejected). Maps onto Claude Code'sUserPromptSubmit.agent/pre-step(serial) — mutate the session surface before the step opens and history is derived (compaction). Fires afterturn/startand beforestep/start, so a listener's appended events land outside the step.agent/request— shape the call config before the model call: a frozenLlmCallConfigseed in, a replacement out (model switching, sampling overrides). Content is not shapeable here — every request is a pure function of the session log (reconstructability RFC); the loop logs whatever config the request actually uses as arequest/header*eventagent/step-result— post-process the assembled assistant message before tool dispatch (validates what the log records)agent/turn-continuation— override the continue/stop decision viaContinuationDecision={action:'stop'}or{action:'continue', reason?}(acontinuereasonis recorded as next-step steering in the same turn — the typed/goalpattern). Force-continue/loop, force-stop budget guard.
Tool interception is the tools/pre-execute / tools/post-execute pair in dsh-tools (PreToolDecision allow/deny/ask, PostToolDecision accept/block) — same typed-Decision idiom, owned there because it is the tool registry's seam.
Error notifications (emit)
agent/error— step/turn error
The model's token stream is NOT an agent/* event: read it off the durable session/event feed as assistant/chunk (the same feed persistence and the ACP bridge use).
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.
What is NOT here (TODO)
- Inter-agent channels beyond delegation — shared state, streaming child output, and background/poll semantics remain outside the current synchronous
ctx.subagentsseam.