The public Agent handle exposed abort() (step-only) and cancel() (queue-aware).
No production caller used abort() — ACP maps session/cancel to cancel(), and
lifecycle owners tear down via AgentHandle.dispose(); the loop's own stop paths
abort their per-step AbortController directly. So abort() is latent generality
that keeps a private loop mechanic public.
RFC-premise correction: the public-agent-stop-surface RFC proposed removing
whenIdle() too. Implementation found whenIdle() load-bearing — a real
quiescence primitive with a deliberate loop contract (settle-without-transition,
the replacement-turn race) and ACP test consumers; its proposed replacement
("observe the running->idle transition") is exactly the async-state race
AGENTS.md warns against. So only abort() is removed; whenIdle() stays. The RFC
is amended on the way to implemented/ to record the narrowed scope, and the new
AGENTS.md "RFCs are proposals, not golden truth" principle (PR1) gets its
worked example.
- Remove Agent.abort() from the interface + the ReactLoopAgent impl; the no-arg
'aborted' default goes with it (cancel() keeps its 'cancelled' default).
- Migrate tests: empty-queue abort() -> cancel(reason); the two review-fixes
tests whose subject is the in-flight step's AbortController drive that
controller directly via the private currentAbort field (cancel() would clear
the inbox and destroy the queued steering one of them proves survives a step
abort). The no-arg-default test is dropped (cancel()'s default is already
covered in cancel.spec.ts).
- Resulting public stop surface: cancel() + whenIdle(). Update agent/agent-loop
READMEs, architecture.md, core.md type-equiv, the extension cookbook, the
lifecycle RFC (short note), and the proposed ACP RFC.
Implements docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md
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). 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 is the production consumer (one handle per session, disposed on disconnect/teardown); 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)
Turn/step boundaries (emit)
agent/turn-start,agent/turn-end(carriesTurnEndReason)agent/step-start,agent/step-end
Interception seams (waterfall)
agent/request— mutateGenerateOptionsbefore the model call (hooks, compaction, model switching, tool filtering)agent/step-result— post-process the assembled assistant message before tool dispatch (validates what the log records)agent/turn-continuation— override the continue/stop decision (force-continue /loop, force-stop budget guard)
Streaming + tool (emit)
agent/stream-chunk— raw chunk from the model (token-level UI/log feed)agent/steering— steering content injected mid-turnagent/error— step/turn error
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), the signal a teardown awaits (a lifecycle owner disposes the handle, which aborts in-flight work then awaits this). Observes the transition without disposing the agent.agent.session,agent.status,agent.options,agent.id
Extension points
- Agent creation:
AgentLoop.create()is the concrete implementation (indsh-agent-loop). Replace the loop by implementingAgentand registering viactx.agents.register(). - Event listeners: all
agent/*events are declared here — no dependency on the loop package needed.
What is NOT here (TODO)
- Sub-agent spawn/fork — seam on
AgentLoop.create(), semantics deferred.