Background Tasks & Scheduling
Command Code no longer sits and waits. Dev servers, log tails, long test suites, parallel research agents, and scheduled reminders can all run in the background while the agent keeps working - and the agent gets woken up when there's something to look at, instead of polling.
This page covers the full background surface:
| Capability | Tools |
|---|---|
| Background shell commands | shell_command (run_in_background), bash_output, task_output, shell_tasks, kill_shell, task_stop |
| Monitors | monitor_command, monitor_events |
| Structured task ledger | task_create, task_update, task_list, task_get |
| Session checklist | todo_write, the /todos manager, the TODOS panel |
| Background sub-agents | the agent tool (run_in_background), agent_output |
| Cron scheduling | cron_create, cron_list, cron_delete |
| Waiting | sleep |
| The TUI manager | the Background panel (Ctrl+B) |
Any shell command can be started as a tracked background task instead of blocking the turn. The agent passes run_in_background: true to shell_command:
- The tool returns immediately with a task id, status, PID, working directory, and the path of an on-disk output log.
- The command's combined stdout/stderr is mirrored to that log file as it runs, so the full output is always on disk and tail-able - even output that never fits in the model's context.
- The task is listable with
shell_tasks, readable withbash_output/task_output, and stoppable withkill_shellortask_stop.
Task ids are short and stable for the session: shell tasks start with s (s0abc123), monitors with m (m0abc123). Logs live under the OS temp directory (<tmp>/commandcode/shellout/<timestamp>-<taskId>.log).
Reading output: bash_output
bash_output reads the output a background task has accumulated so far, plus whether it's still running:
- Takes the task
idreturned byshell_command(a raw runtime background id also works). - Output is tail-bounded to 30,000 characters inline - a chatty dev server can't flood the context. When the buffer exceeds the cap, the result keeps the newest output and prepends a note naming how much was cut and where the full log lives on disk:
The newest output matters most (test verdicts, final errors); for anything older, the agent greps or reads the on-disk log directly instead of re-pulling megabytes through the context window.
Reading output: task_output
task_output is the richer read - it unifies "wait for my build" and "peek at my server" in one tool:
| Parameter | Behavior |
|---|---|
| (default) | Blocks until the task settles, up to timeoutMs (default 30s, capped at 600s), then returns the output tail with exit status - the right call after starting a build or test run you need the result of. |
block: false | Returns immediately with whatever has accumulated (not_ready while the task still runs). |
fromOffset | Forward-streaming delta read from a byte offset; the stored per-task offset advances on a successful read. |
maxChars | Per-read size cap (default 32,000 characters, hard cap 160,000; overridable per-process via COMMAND_CODE_TASK_MAX_OUTPUT_LENGTH). |
Over the cap, task_output keeps the tail and points at the on-disk log for the rest. Every result reports a retrieval status (success, not_ready, or timeout), the task's status, exit code, signal, and log path.
Untrusted-output fencing. Output from a child process is data, not instructions. task_output wraps it in an explicit fence and defangs any fence-marker collisions inside the content, so log output can never masquerade as user or system instructions:
Listing tasks: shell_tasks
shell_tasks lists every tracked shell and monitor task - id, kind, status, PID, command, cwd, timestamps, and output log path. By default it includes settled tasks (completed, failed, stopped); pass includeStopped: false to show only what's currently running.
Task status is reconciled lazily on every read: a task whose process exited since the last look settles to completed (exit 0) or failed (anything else) the moment anything lists or reads it.
Stopping tasks: kill_shell and task_stop
kill_shell stops a background task or terminates a process - by exactly one of three targets:
| Target | What happens |
|---|---|
taskId | The tracked task is stopped through the shared registry: its whole process group gets SIGTERM and the task's state flips to stopped in one step, so process cleanup and task state stay in sync. |
pid | The PID is first reconciled back to a tracked task (directly, or via its process group - so killing a dev server's child listener stops the whole tracked task). Untracked PIDs get graceful-then-force: SIGTERM, up to 5 seconds of polling, then SIGKILL with a 2-second confirmation window. |
port (1–65535) | The listening process is found (lsof on POSIX, Get-NetTCPConnection on Windows) and killed the same way. |
Group-kill safety. Only tracked tasks are ever stopped by process group. An arbitrary PID the model asks to kill is never escalated to a group kill - signalling -pgid for an untracked PID could take down every unrelated process sharing that group, including your own shell. The tool also rejects non-positive PIDs outright: to kill(2), 0 and -1 are broadcasts, not processes.
No false success. kill_shell verifies the target actually exists before killing (a process it can't signal due to permissions still counts as alive), and reports a real error if the process survives the force kill - never a false "successfully killed".
task_stop is the taskId-focused sibling: it stops a tracked task by id (SIGTERM to its process group), marks it stopped in the registry, and distinguishes "unknown id" from "already settled" (echoing back the settled status). Use task_stop for tasks you started; use kill_shell when you need to target a PID or free a port.
Exit-code honesty
Command Code reports exit codes the way your own shell would:
- Signal deaths are never exit 0: A child killed by a signal reports the shell-convention code
128 + N(SIGKILL → 137, SIGTERM → 143) alongside the signal name, so an OOM-killed test run reads asExit code: 137/Signal: SIGKILL, not a silent success. - Conventional non-error codes are annotated: Exit 1 from
grep/rgmeans "No matches found", fromdiff/cmp"Files differ", fromtest/["Condition is false" - the result names the meaning (Exit code: 1 (No matches found)) so a result is never mistaken for a failure.
monitor_command starts a long-running monitor - a tracked background task purpose-built for things you observe over time: log tails, watch commands, dev-server health streams, file watchers. It always runs in the background and returns a monitor task id, PID, cwd, and output log path.
| Parameter | Behavior |
|---|---|
command / args | The monitor command; args are shell-quoted. |
directory | Optional working directory - validated to exist and stay inside the workspace, same as shell_command. |
description | Short human label ("frontend dev server logs") shown in listings and wake-ups. |
maxDurationMs | Optional auto-stop: the monitor is stopped with SIGTERM after this long. Clamped to a positive value ≤ 24 hours. |
notify | "scheduled" (default) or "never" for fully silent recording. |
checkAfterMs | With notify: "scheduled", when to send the first wake-up. Default 45,000 ms; clamped to ≤ 24 hours. |
Scheduled wake-ups - the agent never polls
With notify: "scheduled" (the default), the runtime automatically wakes the agent twice:
- Once after
checkAfterMs- a tiny hidden ping saying the check is due. - Again when the monitor exits - including its final status, exit code, and signal.
The ping deliberately contains no monitor output; it names the task, PID, and log path and tells the agent to call monitor_events if the output is actually needed. Wake-ups arrive as automated user turns between the agent's turns - the agent is explicitly instructed not to poll or repeatedly call monitor_events while waiting. Start the monitor, keep working, get woken when it matters.
Delta reads: monitor_events
monitor_events reads new output from a monitor without rereading the whole log:
- Each monitor tracks a stored byte offset. Call
monitor_events({ taskId })and it reads from the stored offset and advances it - repeated calls stream forward through the output. - Pass an explicit
fromOffsetfor a one-off read from a specific byte position (the stored offset is left alone). maxBytesdefaults to 8,192 bytes per read (capped at 1 MiB), keeping monitor reads cheap.
Monitors have full lifecycle parity with background shells: listable via shell_tasks, readable via monitor_events / task_output, stoppable via kill_shell or task_stop by taskId.
task_create / task_update / task_list / task_get maintain a durable, dependency-aware work ledger - a persistent upgrade over the session checklist for coordination-heavy work.
What makes it a ledger rather than a checklist:
- Stable ids - every task gets a monotonic id that survives restarts.
- Persistence - tasks are stored on disk (under the OS temp dir), so restarts and other processes sharing the same list id see one shared list. The list id can be pinned across processes with the
COMMAND_CODE_TASK_LIST_IDenvironment variable (default:session) - that's how sub-agents and multi-process handoffs share one board. - Dependency edges -
blockedBy/blocksencode ordering. Edges are symmetric and can be added at create time or later. - Owners and metadata - tasks carry an
owner(set it to claim a task) and arbitrary key/valuemetadata(merged on update; set a key tonullto delete it). - Plan-safe - all four tools mutate only the task list, never the workspace, so they work in plan mode.
The lifecycle
Tasks move pending → in_progress → completed (with deleted as a terminal removal that also clears the task from all dependency lists). The workflow rules match the checklist: mark a task in_progress before starting it, completed immediately after finishing, keep exactly one task in_progress at a time, and never mark something completed while tests fail or work is partial.
Completing a task reports which blocked tasks it just unblocked, so the next piece of work is picked up right away. task_list shows id, status, subject, owner, and unresolved blockers (completed blockers are dropped from the display) plus a completion tally; task_get returns a task's full detail - read it before starting work, and verify its blockedBy list is empty.
When to use the ledger
Use task_create only when its features matter: handing work to sub-agents or sharing a list across processes, encoding ordering as dependency edges, work meant to outlive the session, or capturing a plan in plan mode. For an ordinary in-session checklist - and always when you ask for "todos" - todo_write is the tool.
todo_write is the session TODO list - the visible progress tracker for multi-step work in the current conversation:
- Every call carries the complete list (it replaces, never appends) of
{content, status, activeForm}items.contentis the imperative form ("Run tests");activeFormis the present-continuous label shown while the item is in progress ("Running tests"). - The list renders live in the TODOS panel in the TUI, driven directly from the tool input.
- The
/todosmanager lets you edit the list from the TUI - and your edits stick: items you delete stay deleted (the model is told not to re-add them), and items you mark completed stay completed (a stale resend can't un-finish your work). When a plan finishes that you partly drove, the model is told to attribute honestly. - When every item is completed (or an empty list is submitted), the checklist clears so the next task starts from a blank slate.
- The tool pushes back on sloppy usage: it calls out identical resubmissions, silently dropped unfinished items, and multiple simultaneous
in_progressitems, and nudges for a verification step before a large plan is closed out.
Rule of thumb: todo_write for this session's progress; the task_* ledger for durable, dependency-ordered, multi-agent work.
The agent tool can run a sub-agent in the background: pass run_in_background: true (or use an agent definition with background: true) and the tool returns immediately with an agent id while the child keeps working:
Background agents are detached from the parent turn: cancelling or aborting the spawning turn does not kill them - each runs under its own abort controller, and only an explicit kill stops it. Statuses are running, completed, failed, and killed.
Collecting results: agent_output
| Call | Behavior |
|---|---|
agent_output({ agent_id }) | Default action wait: blocks until the agent finishes and returns its full result. Aborting the waiting turn cancels the wait, never the agent - it reports "still running in the background". |
agent_output({ agent_id, action: "status" }) | Peeks at the current state without blocking (running agents report elapsed time). |
agent_output({ agent_id, action: "kill" }) | Interrupts a running agent. |
agent_output({}) | Omit agent_id to list every background agent with its status, type, and description. |
This is the fan-out pattern: launch several background agents on independent subtasks, keep working on the main thread, then wait on each as its result becomes load-bearing.
Running background work is always visible in the TUI:
- The bottom status bar shows a live badge with running counts - "3 shells, 2 monitors" - plus a
↓ to managehint whenever anything is running. - Ctrl+B (or ↓ from the empty input) toggles the Background panel: a manager overlay listing every shell and monitor task, grouped by kind when both exist, with live status colors (running, completed, failed, auto-stopped, stopped).
Inside the panel:
| Key | Action |
|---|---|
| ↑ / ↓ | Select a task |
| Enter | Open the details view - status, elapsed runtime, the full command, and a live-updating tail of the last 10 output lines |
x | Stop the selected running task (same registry stop the tools use: group SIGTERM + state flip) |
| ← / Esc | Back to the list / back to the feed |
The panel polls the shared task registry once a second, so it reflects exactly what shell_tasks would report.
cron_create schedules a prompt to be enqueued at a future time - recurring on a cron schedule, or once at a specific time. It's the tool behind "remind me at 2:30", "every 5 minutes, check the deploy", and "check back in an hour".
- Five-field cron, local time:
minute hour day-of-month month day-of-week, in your timezone -0 9 * * *means 9am local, no conversion. Numbers,*,*/step, ranges, and comma lists are supported (no names). Expressions that can never match a calendar date (like Feb 30) are rejected. - Recurring vs one-shot:
recurring: true(the default) fires on every schedule match;recurring: falsefires once, then auto-deletes. - Fires between turns, never mid-query: a due job enqueues its prompt as an automated user turn once the agent is idle.
- Job cap: at most 50 scheduled jobs across durable and session stores.
Durability
By default, jobs are session-only: nothing is written to disk, and the job is gone when Command Code exits. Pass durable: true - meant for when you explicitly ask for persistence ("keep doing this every day", "set this up permanently") - and the job is persisted to ~/.commandcode/cron/jobs.json, surviving restarts. A durable one-shot that was missed while Command Code was closed is surfaced for confirmation on the next launch rather than firing silently. Only one process at a time fires durable jobs (a scheduler lease prevents double-fires when several sessions are open).
Deterministic jitter
The scheduler applies per-job jitter derived deterministically from the job id - the same job jitters identically across restarts (no schedule drift), while different jobs spread apart:
- Recurring jobs fire up to 10% of their period late, capped at 15 minutes.
- One-shot jobs landing exactly on a
:00/:30wall-clock mark fire up to 90 seconds early (never before creation); off-minute one-shots run exactly on time.
This is anti-thundering-herd protection - and it's why Command Code prefers 57 8 * * * over 0 9 * * * when a request is approximate ("every morning around 9").
Auto-expiry
Recurring jobs auto-expire after 7 days: past that age a job fires one final time, then is deleted. Command Code tells you about this limit when it schedules a recurring job - re-create the job (or ask for it again) to keep it going.
Managing jobs
cron_listshows every scheduled job - id, humanized schedule, recurring/one-shot,[session-only]marker, and a prompt preview.cron_deletecancels a job by the idcron_createreturned.
Kill switches
| Environment variable | Effect |
|---|---|
COMMANDCODE_DISABLE_CRON=1 | Disables cron entirely - all three tools refuse with an explanatory error. |
COMMANDCODE_DISABLE_DURABLE_CRON=1 | Silently downgrades durable: true requests to session-only jobs; nothing is ever written to disk. |
sleep is a first-class wait: the agent pauses without holding a shell process, and without burning the agent loop the way shell_command sleep N would (which is blocked anyway for N ≥ 2 seconds in the foreground).
| Parameter | Behavior |
|---|---|
seconds | Duration in seconds (fractional allowed, e.g. 0.5). Exactly one of seconds or until. |
until | Sleep until a point in time: an ISO-8601 timestamp, or a local wall-clock "HH:MM" / "HH:MM:SS" - the next occurrence, so a time already past today means tomorrow. A timestamp already in the past ends the sleep immediately. |
wake_on_input | Default true: the sleep ends early within about a second of you typing new input, so a message sent mid-wait is acted on at once. Set false only when the full duration must elapse (e.g. a rate-limit backoff). |
reason | Optional short note ("waiting for deploy to finish") shown while sleeping. |
While sleeping, the tool streams a once-per-second progress line to the feed (Sleeping - 1m 30s / 5m - waiting for CI), and you can interrupt it at any time. It's read-only and concurrency-safe, so it can run alongside other tools in the same batch.
Long waits are chunked. Each call is capped (default 10 minutes); a capped result names the remaining time so the agent calls sleep again to continue - each wake-up is one API round trip, which also keeps the prompt cache warm across genuinely long waits.
Duration policy lives in settings.json:
minDurationMs raises short sleeps (throttling wake-up frequency and API calls); maxDurationMs caps long ones per call - set it to -1 to remove the cap entirely. When a request is reshaped by either edge, the result says so and by how much.
Watch a dev server while coding
Start the server as a monitor, keep working, get woken when something happens:
Command Code starts pnpm dev via monitor_command (scheduled notify), works on the fix, gets the hidden wake-up ~45 seconds in, and reads only the new log output via monitor_events - no polling, no context flooded with server chatter. If the server needs restarting, kill_shell({ taskId }) frees it cleanly (or kill_shell({ port: 3000 }) if something else is squatting on the port).
Long test suites and builds
shell_command with run_in_background: true starts the suite; the refactor continues; a blocking task_output({ taskId }) collects the verdict - waiting up to its timeout for the run to settle and returning the tail with the exit code. A signal-killed run reports 128+N, never a false pass.
Schedule a nightly task
"Permanently" makes it a durable job: cron_create with "57 8 * * 1-5" (an off-minute, since "about 9am" is approximate), durable: true - persisted to ~/.commandcode/cron/jobs.json, surviving restarts, auto-expiring after 7 days with a heads-up. cron_list to review, cron_delete <id> to cancel.
Fan out research to background agents
Three agents launch with run_in_background: true; each returns an agent_id immediately. The main thread keeps its context clean while the agents dig, then agent_output waits on each in turn and merges the findings. A stuck agent gets action: "kill"; the launching turn can even be interrupted without losing any of them.
Wait for something external
One sleep({ seconds: 480, reason: "waiting for deploy" }) - no shell held, progress streamed, and if you type "it's done early", the sleep wakes within a second and the smoke test starts immediately. For a fixed time instead: sleep({ until: "14:30" }).
| You want to… | Use |
|---|---|
| Run a dev server / watcher while working | shell_command + run_in_background or monitor_command |
| Get woken when a long process needs attention | monitor_command with notify: "scheduled" |
| Wait for a background build/test result | task_output (blocking) |
| Peek at a running server's latest output | bash_output, or task_output with block: false |
| Stream only new output from a watch | monitor_events (or task_output with fromOffset) |
| Stop a task / kill a PID / free a port | task_stop (by taskId) or kill_shell |
| Track this session's progress visibly | todo_write (+ /todos) |
| Coordinate durable, ordered, multi-agent work | task_create / task_update / task_list / task_get |
| Parallelize independent subtasks | agent tool + run_in_background + agent_output |
| Run something at a future time, once or on a schedule | cron_create |
| Just wait, interruptibly | sleep |
- Interactive mode: the TUI where the Background panel and TODOS panel live
- Custom agents: define the sub-agents you fan out to
- Hooks: deterministic guards around every tool call, background ones included
- Questions? Join our Discord community