* feat(workspace): stop a Session's running work before archiving it Archiving was a pure visibility write, so a Session archived mid-turn kept running out of sight. The Workspace registry now owns archive admission through two Host events: `workspace/session-activity` reports what still runs and refuses the plain archive as `workspace/session-active`; `workspace/session-stop`, requested with `stopActivity`, stops that work the way the user's own stop actions do before the archive write. The API Session Controller reports and stops turns, subagent descendants, and owned jobs and rejects any model step for an archived Session; the Schedule plugin reports and deletes active reminders. The sidebar turns the refusal into a stop-and-archive confirmation listing the Host's answer. * test(workspace): pin the widened archive signature and prove child liveness by heartbeat The Client inspect catalog spec pins archiveSession's new options parameter, and the archive e2e reads the Bash child's liveness from a heartbeat file instead of a pid: the Linux sandbox runs the tool in its own PID namespace, so the pid it reports is not comparable from the test. * test(workspace): cover the archive-admission providers and dialog to the per-file gate Drop the provider's unused disposer and the dialog's unreachable confirm guard, read reminders without a catch that no composition can reach, and add the cases the coverage lane found missing: a stop without a jobs registry, a looping lineage, a descendant whose projections reject, the dialog's Escape guard and non-Error reason, and a family merged by another provider. * fix(workspace): write the archive before stopping, gate subagent lineage, and delete reminders under the schedule barriers Review round on #4765. The registry now writes the archive set before it dispatches `workspace/session-stop`, so the durable set already gates every wake the stops induce; the controller's `agent/pre-step` gate also blocks subagent descendants of an archived Session and contains each cancel or kill on its own. The Schedule plugin's stop listener runs as a management delete in the agent's transaction queue between the same two persistence barriers the `schedule_delete` tool uses. The confirm dialog selects singular or plural English lines and keys list rows by index. Docs: archive-admission section with type-equiv blocks on the workspace subsystem page, write-first order and residual windows in the READMEs and the Agent Note, and the missing zh reminders sentence. * test(web): scope the question composer's New Session wait to the Sessions tree The wide sidebar's New Session button carries the same visible label as a fresh session row, so the unscoped `getByText` either settled on the button before the row existed or hit a strict-mode violation once the row rendered first, which is how the snapshots lane failed on ece68c2ab5. Waiting inside the Sessions tree names the row alone. * fix(schedule): answer archive admission from the owned runtime's fold, not the projection registry The `sessionProjections.snapshot` API is the Client projection view and is not a Host read path (review on #4765). `ScheduleRuntime.activeRecords()` folds the live log the way every drive does, and the archive-admission listeners ask the owned runtime for that fold: a session without a live agent or without an owned runtime has nothing armed to fire, so it reports nothing and has nothing to stop. Covers the runtime-less agent, a fold that turns unreadable at the persistence barrier, and the stopping/faulted runtime answering nothing. * refactor(subagent): report and stop subagent descendants from the Subagent runtime The `subagent` family of archive admission moves out of the API Session Controller into `dsh-subagent`, which records the lineage at delegation and owns the descriptor (review on #4765): `SubagentRuntime` answers `workspace/session-activity` with the running descendants, labelled from each child's descriptor through a live Session observation, and `workspace/session-stop` by cancelling each of them with the parent cause. The controller keeps turns, owned jobs, and the lineage step gate, and composes its provider through `ctx.plugin(SessionActivityProvider)` with declared injections instead of a bare installer call. `dsh-workspace` becomes an optional type-only peer of `dsh-subagent`. * refactor(jobs): answer archive admission for owned jobs from the job registry seam The `job` family moves out of the API Session Controller into `dsh-jobs` (review on #4765): the seam's constructor installs the `workspace/session-activity` and `workspace/session-stop` listeners for every implementation through the abstract `list` and `kill` alone, so the controller no longer knows the jobs vocabulary and keeps only the turn family and the archived-lineage step gate. `dsh-workspace` becomes an optional type-only peer of `dsh-jobs`. * refactor(workspace): leave SessionActivityKindMap empty and let each provider merge its family The workspace package no longer names any activity family (review on #4765): `SessionActivityKindMap` is empty, and each provider merges its own key from a module both its faces import — `turn` in the API Session Controller's types, `job` in the job registry seam's view, `subagent` in the Subagent runtime's control types, `schedule` in the Schedule plugin's types. The confirm dialog imports those modules for its cases and keeps the generic line for any other key; the registry and controller suites merge test-owned families instead of borrowing the providers'. * refactor(agent): report and stop the running turn from the Agent registry The `turn` family moves out of the API Session Controller into `dsh-agent` (review on #4765): the registry's constructor installs the `workspace/session-activity` and `workspace/session-stop` listeners for every Agent it publishes, answering with its own lookup and cancelling the turn with the user cause, and merges the `turn` key from its client-safe types. The controller keeps only the archived-lineage step gate, renamed `ArchivedSessionGate`. `dsh-workspace` becomes an optional type-only peer of `dsh-agent`; the workspace package depends on none of the providers, so the edge stays acyclic. Since #3127 a foreground Bash command is a registered job from its start, so the archive dialog lists it beside the turn; the e2e and its ARIA golden expect that row, and the capture keeps the running job by design.
description, kind
| description | kind |
|---|---|
| The Agent handle, live registry, process-local initiator scope, and agent/* event vocabulary for plugins, UI, and orchestrators building or extending agents. | package-reference |
@deepseek-ai/dsh-agent
English | 中文
Summary
Use dsh-agent to create or resume live agents, send follow-up or steering input, inject model-facing context, cancel work, and wait for idle completion. Plugins, UI, hooks, and orchestrators can also observe or intercept agent activity and apply capabilities to one agent without affecting others. Choose it when code needs to control or extend live agents through the public Agent API. Pair it with an agent driver such as dsh-agent-loop; this package does not create model requests by itself. Initiator attribution is process-local and must be carried explicitly across workers, processes, durable queues, and restarts.
Table of Contents
- Use this package
- Understand the implementation
- Further Exploration
- Model Experience
- Known Limitations and Deferred Work
- Dev Note
Use this package
Mount dsh-agent wherever live agents exist: it provides ctx.agents and the Agent handle that plugins, UI, hooks, and orchestrators work against. The service is inert until a driver registers a factory — the shipped driver is dsh-agent-loop, so the smallest useful composition loads both.
Create or resume an agent
ctx.agents.create() builds a fresh agent and session under one identity; ctx.agents.resume() loads a persisted session and rebuilds the agent on it. Both delegate to the registered factory and return an AgentHandle — the only object that can tear that agent down. Set parentAgent in either operation's options to make the result a runtime child; omit it for a runtime root. get(id), list(), and roots() find live agents, and isOwnedBy(id, parent) tests that exact live relation.
const handle = await ctx.agents.create({
sessionId,
agentOptions: { provider: 'deepseek', model: 'deepseek-chat' },
})
// later:
await handle.dispose() // stops the loop, unregisters, removes the session, unwinds the scope
AgentOptions supplies the initial provider/model route, optional adapter-owned reasoningEffort, and optional positive maxTokens output cap. The loop validates exact-model reasoning support, resolves adapter defaults, records the effective values in the request header, and applies them to each conversation request. An optional setup(agentCtx, agent) callback composes the agent's scoped world before it is published: agentCtx owns registrations, while the explicit unpublished Agent provides its Session; the Context has no reverse Agent property. Scoped tools, prompt sections, and listeners exist before any creation announcement. Setup is composition-only: drive the agent only after creation resolves.
Drive an agent's conversation
The handle's methods route identified user-role messages into the agent's inbox. followup() queues an ordinary next-turn prompt and wakes the driver; steer() submits next-step input and wakes it; inject() adds model-facing context without waking the driver, so it lands in the next admitted step. cancel(cause) aborts the active activity and, unless keepInbox is set, clears pending work; whenIdle() resolves after the whole agent reaches quiescence.
handle.agent.followup({
content: [{ type: 'text', text: 'Summarize this workspace.' }],
source: { kind: 'user' },
})
handle.agent.steer({
content: [{ type: 'text', text: 'Focus on the tests.' }],
source: { kind: 'plugin', plugin: 'my-plugin' },
})
await handle.agent.whenIdle()
Scope registrations to one agent
Agent.ctx is the agent's scoped context: registrations made through it (tools, prompt sections, variables, event listeners, restrictions) apply to that agent alone and unwind on disposal. The same mechanism is what agent presets use to give one session a different capability set without affecting its neighbors.
Intercept or observe work in flight
The agent/* events let plugins act on live work without depending on the loop package. agent/pre-step can reject a proposed step or replace the messages entering it; agent/request-error lets a listener retry a failed model request; agent/turn-stopping runs before an otherwise completed turn closes and can steer to keep it open. agent/assistant-stream carries one process-local Assistant attempt's ordered start, transient chunk, and end frames. Start names the attempt's turn and step, chunk indexes are dense from zero, and end.index is the next chunk position. The loop commits the complete compact stream as one assistant/message or assistant/attempt before a committed end frame, so the live event remains presentation data rather than the replay source. agent/status, agent/created, and agent/disposed drive UI and coordination state, and the per-message agent/inbox/* notifications keep inbox projections in sync. Exact signatures, dispatch modes, and payload contracts live in the generated region of the core subsystem page.
Understand the implementation
Implementation internals — click to expand
This section explains how the package realizes the behavior above; the observable contract is covered in Use this package.
Design concept
The package is built on one separation: the public Agent surface and registry live here, while construction and driving live in the loop package behind a registered factory. Consumers therefore depend on dsh-agent and never on dsh-agent-loop, keeping the driver swappable. The second idea is the initiator scope: an AsyncLocalStorage chain that carries the exact live Agent through the asynchronous driver work it starts, so helpers below a driver can attribute their work without forwarding the agent through every call.
Step admission
PreStepDecision is either { kind: 'reject' } or { kind: 'enter', messages, startsRequestSeries? }. The enter branch contains the complete identified, frozen message batch. Acceptance does not commit it: after assembly and step/start, agent/request and prepareCall() resolve the route before the loop commits the system prompt and user batch. Cancellation during either async phase commits neither. startsRequestSeries: true declares a distinct model-message series; a wrapping listener preserves that declaration and the batch unless it intentionally replaces either one. Claiming removes offered messages from the inbox, while messages inserted after the claim remain pending for a later boundary.
Durable inbox
Agent.inbox exposes only the structural Inbox interface and the projection vocabulary stays in this package. dsh-agent-loop owns the package-internal ReactLoopInbox and the standard inbox projection; constructing its concrete inbox ensures that the projection registry owns one registration for the durable agent/inbox/spliced fold. The registry remains the sole owner of the live { 'next-turn', 'next-step' } state. Reconstruction rejects unsafe or out-of-range splice coordinates and duplicate MessageId values across both pending lists and reports the offending event seq.
Inbox exposes pending nextTurn and nextStep messages and mutates them through append, prepend, replace, remove, clear, and splice. Ordinary removals and clear() are durable cancellations. At a step boundary, the loop's internal implementation claims pending input through pure deletion splices. Live notifications are deliberately per-message and minimal: agent/inbox/inserted { message }, agent/inbox/claimed { message, turn }, and agent/inbox/discarded { message }.
Source map
| File | Role |
|---|---|
src/index.ts |
Plugin entry: AgentRegistry, factory slot, initiator scope, CreateAgentOptions/ResumeAgentOptions |
src/runtime-types.ts |
Agent, structural Inbox, AgentStatus, and the agent/* event declarations |
src/types.ts |
AgentOptions, cancellation causes, and inbox projection vocabulary |
src/dispatch.ts |
agentEvents fused dispatcher and assembleContextFor(agent) |
src/consumed-work.ts |
foldConsumedWork(events): what the log's consumed work became |
src/model-selection.ts |
installModelSelection: coupling one selection to assembly and routing |
src/invariant.ts |
Invariant companion: no-op agent/status transitions fail |
src/archive-admission.ts |
The turn family of the Workspace registry's archive admission: a running turn and its user-cause cancel |
Registry and lifecycle
AgentRegistry keeps one entry per live agent with its carrier and creator relation. Await register() to finish serial creation listeners with source startup before using an already-constructed agent; the async factory uses the split enter()/announce() pair so setup and initialization stay rollback-covered. A detach requested during creation waits for every awaited listener to settle, and each detach is bound to the exact entry, so a stale disposer cannot remove a later same-id replacement. Teardown stops and drains the loop, unwinds the scope, detaches the agent, then detaches the session; the id becomes reusable after private cleanup.
The registry also answers the Workspace registry's archive admission (seam) for every Agent it publishes: workspace/session-activity reports the turn family while the Session's Agent is running, a turn waiting for an approval or an answer included, and workspace/session-stop cancels that turn the way the user's own stop does — agent.cancel({ kind: 'user' }), but without the stop button's keepInbox, so queued input is discarded with a logged inbox splice instead of waking the archived Session later. Nothing is awaited to settlement; a Session without a live Agent has no turn that could run. The turn key is merged into SessionActivityKindMap from src/types.ts, so a Client that renders the families imports @deepseek-ai/dsh-agent/types for it.
Initiator scope
Each driver runs its complete lifetime inside ctx.agents.withInitiator(agent, ...), so inherited async chains observe that agent; withoutInitiator() hides it for unrelated process-local work such as shared timers. The boundary is process-local attribution only — ambient presence is neither liveness proof nor authorization, and explicit identity stays authoritative at worker, process, persistence, and wire boundaries. Teardown rejects new boundaries, lets returned-Promise boundaries drain, then disables the underlying storage. The initiator-scope decision owns the detailed contract.
Ownership invariants
The AgentHandle disposer is a capability: among consumers, only its holder can tear the agent down. The registered factory provider is a structural co-owner, because scoped agents depend on that provider's service API; provider unload stops and drains every live handle it made. ctx.agents.get(id) still returns a bare Agent — the handle is exposed only to the consumer that created it.
Further Exploration
The package-level contract is enough for most consumers; read these when you need the surrounding domain and the design rationale.
- Core subsystem — the loop map,
Agenthandle, interception decisions, and generated service API. - agent-loop package — the default driver that creates, drives, and disposes agents.
- Session subsystem — the durable log and derived history behind the handle.
- Initiator-scope Agent Note — the boundary and teardown contract.
- Core group map — how the core packages compose.
Model Experience
User, steering, and injected messages
What the model sees
followup, steer, and inject feed the owning session as identified user-role messages; accepted content becomes part of the derived history the model reads on later steps. agent/pre-step and the other declared events let plugins reject a proposed step or add durable request material. installModelSelection adds [model changed: assistant turns above this point were generated by <previous>; the session continues with <next>] to the first step assembled for a different provider/model route that would send a model request; provider names appear only when the switch crosses providers, and reasoning-effort-only changes add nothing. An empty first decision and a decision that removes offered messages remain no-request results. If a request step fails before logging its header, the next request step receives the notice again because the durable previous route has not changed.
Token effect
Accepted content becomes retained history or a repeated session prefix; blocked content contributes no request tokens. Each emitted model-switch notice adds its text to retained history. Size is caller- and plugin-dependent.
KV Cache effect
Accepted history and steering are append-only; a blocked submission sends no request. A session prefix remains stable within its loop instance, while a new or resumed instance may establish a different prefix.
Agent-scoped request composition
What the model sees
Registrations through agent.ctx can shadow prompt sections or tools and can install agent-only interceptors during unpublished setup, so one agent sees a different prompt and tool set than its neighbors. Model selection captures one provider/model/effort value before prompt assembly and applies it to the same step's request; a later concurrent change waits for another step.
Token effect
Each provider/model switch adds one short retained user-role notice. Other scoped contributions affect only that agent and disappear on disposal.
KV Cache effect
The switch notice appends after the previous history, preserving that prefix, while the route change can prevent the new provider or model from reusing it. Setup or reload that changes prompt sections, tool definitions, or request listeners may invalidate reuse from the first affected request token.
Known Limitations and Deferred Work
These limits define when this package needs special care. They are current package constraints, not a task backlog.
- Initiator scope is process-local — workers, child processes, HTTP, durable queues, and restarts must materialize any required identity explicitly.
- Ambient identity may outlive liveness — consumers still check
agent.status, cancellation, and the owning capability contract before lifecycle-sensitive work. - Creation listeners share the initialization lifetime. An
agent/createdlistener must not awaitagent.whenIdle()or its own owner's disposal: those operations wait for creation to finish. Return only after required asynchronous tool and prompt installation completes. cancel()clears the inbox by default — it aborts the in-flight turn plus queued and steering work;cancel(cause, { keepInbox: true })aborts only the turn and preserves pending items, and there is no step-only abort that keeps the turn running.- Each additional
UserMessagecarries exactly oneMessageSource— contributions from several plugins merged onto one message collapse under one source, so the message cannot name several producers.
Dev Note
Working context for maintainers — click to expand
This Dev Note is working context for maintainers; it is explicitly non-authoritative. Open, undecided directions: inter-agent channels beyond delegation — shared state, streaming child output, and background or poll semantics remain outside the current delegation seam; and the SessionStartSource values 'clear'/'compact' are reserved with no emitter yet, pending the driving subsystems.