A name resolved through an export list (or a default-export identifier) mapped back to its whole VariableStatement, and checkDecl walked every declarator — so a private sibling sharing the statement with an exported const was wrongly required to carry JSDoc. The scope dispatch is now two-phase: requests accumulate per statement (null = whole statement for a direct export modifier or ambient scope; name sets union across lists, so two lists naming different declarators of one statement both count), then each surfaced statement is checked once with the declarator filter. Regressions pin the private-sibling skip, the cross-list union, and the default-export sibling.
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.