Skip to content

This capability family covers three ways an agent keeps working around its main turn loop: a todo list the model owns, background jobs that outlive a single turn, and scheduled reminders that return later as ordinary conversation turns. They share a theme — durable, session/session-id-scoped state that the model reads and mutates through small tool surface — but live in distinct packages with distinct semantics.

PackageTopicRole
packages/todo/tool-todoTodomodel-facing todo_write tool + todos projection
packages/jobs/jobsJobsservice definition (ctx.jobs) + shared types
packages/jobs/jobs-localJobsprocess-local provider (LocalJobRegistry)
packages/jobs/tool-jobsJobsmodel-facing job_* tools + completion delivery
packages/schedule/scheduleScheduledurable reminders + schedule_* tools

Todo: packages/todo/tool-todo

Todo state is session-owned, whole-list-replacement state. The single tool todo_write takes an entire todos array and replaces the previous list — there are no per-item edits or add/update/complete operations. Each item is { content, status } where status ∈ { pending, in_progress, completed }.

execute appends a todo/write event to the owning agent's session (exec.agent.session.append('todo/write', { todos })); replay is last-write-wins and UIs render from these session events. A non-agent caller (no owning session) is rejected. The tool validates what the schema cannot: trimmed non-empty unique content, and at most one in_progress item unless allowParallelInProgress is set.

jsonc
{ "todos": [ { "content": "Refactor the agent loop", "status": "in_progress" } ] }

The config key allowParallelInProgress is required with no default — a deployment must explicitly choose whether several todos may be active at once. It only rewrites the tool's description: parallel mode asks the model to mark every actively-worked task; single mode asks for exactly one and rejects a call marking more.

When the session-projection seam is composed, the plugin also registers a todos projection (key 'todos', value TodoItem[] | null):

  • on todo/write → the new list;
  • on turn/startnull (cleared; turn/end keeps the finished checklist visible);
  • otherwise → the same state reference.

The background-job runtime: packages/jobs/*

Jobs are long-running background work that outlives a tool call — background bash, PTY terminal_send, and subagents all register with the generic ctx.jobs runtime, and are read/listed/killed through the same three tools.

The service definition — ctx.jobs

JobRegistry (packages/jobs/jobs/src/index.ts) is an abstract seam. A JobId is a branded id shaped <kind>-N — predictable on purpose, because access control is authorization, not secrecy (fenced by the owner session id). JobKind derives from a merge-extensible map; producers extend it by declaration merging:

ts
interface JobKindMap { bash: 'bash'; subagent: 'subagent' }
// plugins add more kinds by merging this interface

JobStatus is 'running' | 'stopping' | 'completed' | 'killed' | 'failed'; producer-specific facts belong in JobSnapshot.detail. The abstract surface:

start(spec) -> JobId            list(caller?) -> JobSnapshot[]
get(id, caller?) -> JobSnapshot read(id, caller?) -> JobRead
kill(id, caller?, reason?)      wait(id, timeoutMs, caller?, signal?)
onJobDone(listener)             onJobsChanged(listener)
attachController(name)

JobStart declares a kind, a one-line label, an optional outputLimitBytes, an owning Agent (owner), and a synchronous run(): JobHooks. JobHooks exposes cancel(reason?), a done: Promise<JobOutcome> that resolves only after the producer releases resources, and optional readOutput() that distinguishes consuming stream jobs from final-output-only jobs. Settlement is first-wins: one terminal record, released waiters, one contained listener round; completion is announced last so a reporter may open a model turn synchronously.

The local provider — JobRegistryLocalJobRegistry

LocalJobRegistry (packages/jobs/jobs-local/src/index.ts) is the process-local provider. Config maxConcurrentJobsPerOwner (default 10) is a positive safe integer counting running + stopping records per exact owner, with one shared bucket for unowned jobs; a producer hitting the cap is told to job_kill an unneeded job before retrying. start refuses work while no attached controller serves the owner (the registry enforces that a producer cannot start work its owner cannot collect or stop).

The model tools — tool-jobs

dsh-tool-jobs names the three tools and attaches a controller:

ToolPurpose
job_outputRead stream delta (or idempotent final output); wait: true + timeout_ms blocks with clamping
job_listList caller-owned/unowned jobs with ids, kinds, statuses
job_killRequest cancellation; returns cancellation-requested or already-finished

Config controls wait bounds and completion notice delivery: waitTimeoutMs (default 30 s), maxWaitTimeoutMs (default 10 min — larger model-supplied values clamp down), completionDelivery ('wakeup' opens a turn for an idle owner, 'quiet' leaves it pending), and maxConsecutiveWakes (default 3, bounds the self-exciting chain where a woken turn starts the job whose completion wakes it again).

Completion delivery is the subtle part. On settlement, the plugin builds a bounded, retainer-truncated completion notice and either:

  • wakes an idle owner (owner.followup(message)) up to the wake budget, or
  • injects into a busy owner's next step (owner.inject(message)) — the notice waits in the next-step inbox, so jobs settling together cost one step.

Interactive consumer: job_output waits up to the cap, and every response ends with a [status: ...] line. Loading tool-jobs also adds the cross-cutting system-prompt guidance: track every background job id you start, don't busy-poll, collect with job_output and use job_kill for jobs that stopped mattering.

Schedule: packages/schedule/schedule

Schedule owns durable reminders that return to the original live Session as ordinary later conversation turns. It is deliberately not a general scheduler: there is no external notification channel and no cold-session scheduler — delivery mode is fixed to session-local, so a reminder runs on time only while its session is live and otherwise becomes overdue until the session resumes. This session-local decay also shows up in the model-facing tools' state field (scheduled | overdue).

The tools

ToolBehavior
schedule_createprompt plus exactly one of after_seconds, at, every_seconds
schedule_listAll active reminders in creation order (id, UTC target, state, delivery)
schedule_deleteDelete by exact id; unknown/finished ids return deleted: false

after_seconds is a positive safe-integer delay; at is either a strict offset-bearing RFC 3339 string or a local { date, time, time_zone } object with an explicit IANA zone; every_seconds is a fixed-rate interval of at least MIN_EVERY_INTERVAL_SECONDS = 300 (five minutes) — there is no Cron expression, recurrence time zone, or shared cooldown. Fixed-rate reminders are creation-anchor-aligned and skip missed occurrences: if a session was cold or busy across several targets, one every record contributes only its latest due occurrence in a batch. Every creation canonicalizes its first target into a four-digit-year RFC 3339 UTC scheduledAt, so replay never depends on ambient time-zone state.

Durable state and lifecycle

The only durable authority is the version-1 schedule/change session event, with operations create, delete, and dispatch (ScheduleChange). Create stores the complete record; delete and one-shot dispatch are terminal id-only transitions; an every dispatch carries acceptedAt (the wall-clock decision time) and normally advances the record instead of terminating it. A strict decoder and fold reject unknown versions, extra fields, reused ids, mismatched shapes, and transitions against inactive records. A fork folds only events at or after SessionHeader.seedLength, so it keeps history but does not adopt the parent's active reminders.

Due work waits for the Agent to become fully idle, claims a maintenance phase, queues one followup() (a normal later turn), and appends the dispatch changes. Delivery is best-effort at-least-once: it appears only through the ordinary transcript (no separate Web receipt), and the narrow crash interval between admission and durable dispatch can repeat reminder content after recovery.

Configuration and tool registration

Schedule tools are registered only inside live root Agent scopes created after the opt-in Schedule plugin loads (registerScheduleTools). Stable tool-level error codes: invalid_prompt, invalid_selector, invalid_rule, invalid_time_zone, not_future, time_out_of_range, frequency_too_high, corrupt_schedule_log, internal_error, plus persistence_uncertain (delivered instead of guessing whether an eager write committed). Management calls serialize through a shared Session-persistence barrier.

Packages

Package
@deepseek-ai/dsh-tool-todo
@deepseek-ai/dsh-jobs
@deepseek-ai/dsh-jobs-local
@deepseek-ai/dsh-tool-jobs
@deepseek-ai/dsh-schedule

Further reading

  • Interactionsfollowup() and inbox-claimed turn delivery
  • Context sources — session-event folds and projections
  • The shell — how bash and terminal tools register run_in_background jobs
  • Subagents — the subagent job kind and its lifecycle
  • docs/subsystems/jobs.md — the Background Task Runtime reference
  • docs/subsystems/schedule.md — durable schedule records, dispatch, and fixed-rate catch-up