Rove

rove api

rove api is Rove's scriptable surface: the verbs a shell script, or another AI agent, uses to spawn tasks, supervise them, read their output, and land the winner, with no TUI attached.

Each invocation is a short-lived process: connect to (or auto-start) the daemon, do the work, print one JSON object to stdout, exit. Read-only verbs marked offline below skip the daemon entirely.

rove api schema is the source of truth when this page and the binary disagree: names, types, required flags, and enum values, as JSON. Agents should read it once and drill in with --verb <name> instead of parsing this page. The rest of the binary is documented in the CLI reference.

To teach a coding agent this surface, install the bundled agent skill with rove skill install instead of pasting this page into a prompt.

One contract, three transports

The verbs on this page are one contract — names, flags, results, error codes — served by one executor over three transports:

TransportCallAnswer
CLIrove api <verb> --flag valuethe result on stdout, or { "error": … } on stderr (below); --envelope prints the envelope instead
Daemon socketrequest api.invoke { "verb", "params" }the envelope
Bridge WebSocket (rove --web, the phone apps)op api.invoke { "verb", "params" }result, or the full error object

The envelope is { "apiVersion", "ok": true, "result" } or { "apiVersion", "ok": false, "error": { "message", "code", … } }, where error is exactly the object the CLI prints on stderr. params are keyed by flag name ("task-id", not taskId) and typed as schema --verb lists them: a string, a number for int/uint, a boolean for bool, a string list for csv.

apiVersion is the one version of this contract. rove api schema, both hellos (daemon and bridge) and every envelope report the same number.

Over the socket and the bridge the verb runs inside the daemon or the bridge process, not beside the caller, so three things differ from the CLI:

  • Paths must be absolute. There is no caller cwd to resolve --repo . against; a relative path is BAD_FLAG. Without --task-id, no $ROVE_TASK_ID applies either: the target is the active task.
  • No stdin. --prompt-file - is BAD_FLAG; pass --prompt.
  • No streaming. watch and pane-graphics (cliOnly: true in schema) answer CLI_ONLY_VERB; subscribe to the daemon's channels instead.

Run a verb on another machine

With machines registered (MACHINES.md), a verb can run on any of them. It crosses the ssh-forwarded daemon socket as api.invoke and executes inside that machine's daemon: its git, its PTY host, its engine logins. This machine only routes.

  • --machine <alias> on any verb runs it there: rove api list --machine narwhal. add is the exception: add --machine <alias> keeps the agent on this machine and puts only its worktree and commands there (add --workspace-on, see add below), unless --engine-there is passed. Over the socket or the bridge, pass "machine": "<alias>" beside verb and params. local names this machine. auto places the call: the machine with the lowest load per core, as each machine's own daemon reports it (this one unless another is lower by more than 0.25; a machine that does not answer, runs an older Rove or is Windows reports none and is skipped), reported on stderr as --machine auto → <alias>. With a --repo it can compare, auto only considers machines that have that repo (the stderr line lists the ones skipped); when none has it, load alone decides. --clone lifts the filter, since any machine can fetch the repo.
  • A --task-id that lives on another machine runs on that machine without --machine, so a remote task the sidebar shows answers send, read-output, delete and the rest like a local one.
  • The rules for socket callers above apply on the far side: paths are absolute paths on that machine. --prompt-file is read here and sent as --prompt. --repo is the exception: a path that is a checkout here, or a repo id (owner/name, host/owner/name, a remote URL), names the repo, and the target is asked for its own checkout of it (repo-match). A checkout there that is only a fork (same history, other origin) is FORK_MATCH unless you pass --allow-fork; no checkout at all is NO_REMOTE_CHECKOUT, or, with --clone and a repo that has an origin, the target clones it.
  • A forwarded call is never forwarded again, and list over the socket returns only the answering daemon's own tasks, so two machines that registered each other cannot bounce a verb between them.

The bridge logs every destructive verb (delete, land, remove-worktree, tab-close, issue-delete, routine-delete, note-delete) by verb and id, as it does its own destructive ops.

Socket limits and recovery

Daemon and standalone PTY Host requests use newline-delimited JSON. Each request may contain at most 8 MiB of UTF-8 wire bytes, excluding its terminating newline. This includes JSON escaping and envelope fields. The receiver closes the connection as soon as an unfinished or complete frame exceeds the limit; it sends no parse-error response for that frame. Multiple smaller frames may share a chunk. Split UTF-8 characters survive chunk boundaries.

The limit uses the existing 8 MiB outbound queue budget as a per-connection resource envelope. It allows multi-megabyte prompts and PTY input; it is not an OS socket limit. Shorten oversized request fields. PTY writers can divide input into ordered pty.write requests. Receive framing scans each new chunk once and grows storage geometrically, so a long line sent in small chunks does not repeatedly scan its accumulated prefix.

A slow reader also has an 8 MiB queue of unsent response/event bytes, in addition to the socket's already accepted write. Complete snapshots for task.snapshot, active-task, update, attention.inbox, gates, ui-prefs, state, machines, worktree.changes, transcript.activity, usage.snapshot, and usage.context replace only an older queued snapshot of the same channel. Other frames retain their order, including per-task engine/job updates, per-repo issues, commands, keybinding notifications, RPC responses, lifecycle events and PTY bytes. If the remaining queue exceeds the budget, the server disconnects that reader. It never silently discards the last snapshot of a different channel to make room.

Rove's GUI and pane orchestrators reconnect and subscribe again to receive current snapshots. Outstanding RPCs reject on disconnect, including requests without a normal deadline. One-shot API callers see a failure; commands are not automatically retried because the server may already have applied them. Transient events have no replay log, so a disconnection does not promise recovery of every command or event. PTY client disconnection detaches the reader and leaves its hosted children running.

The orchestration loop

Running many agents well is graph engineering, not prompt engineering: isolated attempts as nodes, your judgment at the gates. Four moves, one verb each.

Fan out. One prompt, N isolated attempts, one call. add is the create verb whether you want one task or five; --count N (or --agents for a mixed fleet) is what makes it a parallel round:

rove api add --repo "$PWD" \
  --agents claude:2,codex:2,copilot:1 \
  --prompt "Try independent approaches to simplify the auth flow."

Every add response carries a home field: the Rove home the tasks were actually written to. A ROVE_HOME_DIR override that collapses (an unquoted shell variable holding a whole env prefix does not word-split) otherwise reads as a plain success — same count, same empty failures, different machine state. Compare it against the home you meant before trusting the round.

Siblings with no --title are named from --prompt at creation, so a fan-out is comparable the moment it returns rather than showing N identical (new task) rows until the engines write their first transcripts.

Completion. A worker spawned from another Rove task sends one outcome signal back to the dispatching engine tab: creation records the dispatcher (task + tab). If its prompt was armed, that signal is report (Rove then pastes one [ROVE REPORT] line into the dispatcher's tab); otherwise a bare send routes home without any id in hand. No blocking wait. Silence is a checkpoint, never a verdict:

rove api send --prompt "succeeded: auth flow simplified (branch fix/auth-flow)"

Observe. Read the engine's own structured session, never scrape a TUI screen:

rove api read-output --task-id <id>    # newest messages first, honest terminal fallback
rove api read-output --task-id <id> --tab tab-3   # that tab's own conversation or terminal

Fan in. Compare the attempts, then land one:

rove api collect --group <groupId>     # the whole round's health, one read
rove api collect --task-ids a,b,c      # or name the attempts yourself
rove api land --task-id a              # merge the winning branch

Output + exit-code contract

  • Success → one JSON object on stdout, newline-terminated, exit 0. --pretty indents it (humans only).
  • Error → { "error": { "message", "code", ... } } on stderr. Common rejections additionally carry hint and nextCommandArgs (argv runnable verbatim) so a caller can self-heal without parsing prose. A refusal the daemon raises keeps its own machine code — DIRTY_WORKTREE, LAND_CONFLICT, MISSING_REF, ISSUE_NOT_FOUND, TASK_DELETING, TASK_BUSY, MAIN_TASK, GIT_COMMAND_FAILED, BAD_EVENT_KIND, the EMPTY_BRANCH pair — in code, not in the prose. RPC_ERROR now means what it says: the daemon failed without naming a reason. Never match on message; it is written for a human and it no longer repeats the code.
  • Exit codes: 0 success · 1 handler/RPC failure · 2 usage errors (unknown verb, bad/missing flag, unreachable daemon — wherever they are raised, including a handler rejecting its own argument) · 3 a parallel round that did not fully succeed, or a tick should-run / send --if-runnable refused (the verdict is on stdout). Exit 3 on a parallel round does not promise anything was created: --count 1 takes the same path, so a lone failure returns count: 0 with an empty tasks. The full payload still goes to stdout, so whatever was created is never lost.
  • rove api <verb> --help prints that verb's usage and exits 0.
  • --envelope prints every outcome, success or error, as one envelope on stdout (above); exit codes do not change.

Codes the CLI itself raises

Separate from the daemon's refusals above — these never cross the socket:

CodeRaised when
MISSING_VERBrove api with no verb.
BAD_VERBA verb (or schema --verb / --group) name that never existed.
UNKNOWN_VERBA verb that was REMOVED; nextCommandArgs is its replacement.
MISSING_FLAGA required flag was omitted.
BAD_FLAGUnknown flag, bad enum value, or a value the verb could not parse.
BAD_TABA --tab value that is not new or tab-N.
BAD_DAEMONThe daemon could not be reached or started.
DAEMON_VERSION_SKEWThe daemon is a different build and does not serve this verb.
DAEMON_UNREACHABLEThe daemon socket refused the connect (restarting or stopped); the request never left, so a retry is safe.
CLI_ONLY_VERBapi.invoke (socket or bridge) named a verb that streams over stdout/stdin (watch, pane-graphics).
UNKNOWN_MACHINE--machine (or "machine" over the socket) names no registered machine.
MACHINE_UNREACHABLEThe machine a verb was routed to did not answer over ssh.
NO_REMOTE_CHECKOUT--repo names a checkout here or a repo id, and the target machine has no checkout of that repo (repo-match). Pass --clone to have the target clone it.
FORK_MATCHThe target machine's only checkout of the --repo shares its history under another origin (a fork). Pass --allow-fork to use it, or --clone with the repo id.
NO_LOCAL_CHECKOUTadd --repo names a repo id and this machine has no checkout of it. Pass --clone, or --repo with a checkout's path.
CLONE_FAILEDadd --clone could not git clone the repo id into ~/.rove/repos/<host>/<owner>/<name>; the message carries git's error. On a full disk the hint says to free space and run the same command again.
UNPUSHED_BASE--base-branch has commits no remote has; the target machine could not base a task on it.
INBOUND_DENIEDThe target machine's inbound policy refuses this verb's class from other machines (rove machine inbound).
PUSH_FAILEDpublish (or land of a remote task) could not push the task's branch.
MACHINE_VERSION_SKEWThat machine's Rove predates forwarded verbs; upgrade it.
MISSING_TARGETNo --task-id, no $ROVE_TASK_ID, no active task — nothing was named.
NOT_RATE_LIMITEDresume-quota --task-id on a task that is not waiting on a quota reset.
MISSING_TEXTrow-token was given neither --text to write nor --clear to remove.
TASK_NOT_FOUNDAn id WAS named and does not resolve.
TAB_NOT_FOUNDA --tab tab-N the task has no live (or restorable) tab for.
TAB_COLLISIONtab-new found its freshly allocated tab id already running a session; no new tab was created, retry.
FILE_NOT_FOUNDtab-new --kind editor --file names no file in the task's worktree.
NO_EDITORtab-new --kind editor found no editor: none set in Settings → General → Editor, $VISUAL/$EDITOR unset, and none of nvim/vim/emacs/nano installed.
PANE_NOT_FOUNDtab-new --kind pane --pane names no pane of an enabled plugin.
TAB_NOT_SPLITTABLEtab-split on a content tab; only terminal tabs split.
LAST_LEAFtab-split --action close on the only pane; close the tab instead.
RESET_ENGINEtab-split --action reset on a pane that runs an engine; only shell panes reset.
TAB_NOT_REVIVABLEtab-revive on a tab with no session of its own: a preview, a viewport onto another task, or a conversation hosted under another task.
RUN_NOT_FOUNDroutine-respond --run names no routine run (unknown, or pruned from the last 100).
RESPONSE_TOO_LARGEA routine-respond response or a report --summary over the 32,000-character cap.
DELIVERY_NOT_FOUNDreport --delivery names no delivery (unknown, or pruned past the newest 50 for its task).
GATE_NOT_FOUNDgate-answer / gate-withdraw names no gate.
GATE_NOT_OPENThe gate is already answered or withdrawn.
BAD_OPTIONgate-answer --option is not one of the gate's option keys (or the gate is free-text only).
NO_ENGINE_TABThe task has live tabs but none of them is an engine, so there is nothing to deliver to or interrupt.
NOT_A_REPO--repo does not point at a git repository.
NOT_A_GIT_REPOOne of several --repo values is not a git repository (the message names it).
REPO_DUPLICATEadd names the same repo root twice; a subdirectory counts as its root.
TOO_MANY_REPOSadd names more than 8 repos for one task.
REMOTE_EXTRA_UNSUPPORTEDA multi-repo add includes a remote project; every repo must be local.
INVALID_BRANCH--branch is a name git will not accept (git check-ref-format --branch).
REPO_UNRESOLVABLE--repo resolved, but the repository is gone or unreadable.
NO_WORKTREEThe task has no materialized worktree yet.
BASE_CHECKOUTdelete was aimed at the project's own checkout, not a Rove worktree.
CALLER_WORKTREEdelete was run from inside the worktree it would remove.
HISTORY_REQUIREDread-output --source history on an engine with no history reader, or on a non-engine tab.
HISTORY_UNREADABLEThe engine's history exists but could not be parsed.
CURSOR_INVALIDA --cursor value this build cannot decode.
CURSOR_TASK_MISMATCHThe cursor belongs to a different task.
SOURCE_CHANGEDThe cursor's source/session/tab moved under it.
TAB_RESTOREDThe --tab exists with its scrollback but nothing runs in it; pass --respawn.
NO_CONVERSATIONsend --handoff-from tab-N where that tab holds no conversation to hand over yet.
TAB_ALIVEsend --handoff-from tab-N --replace where that tab's engine is still running; only a dead or exited one is replaced.
NO_TRANSCRIPTsend --handoff-from tab-N where the tab's engine keeps no transcript Rove can name (a custom engine).
ENGINE_NOT_RUNNINGThe tab's engine exited into a plain shell, so a paste would run as shell commands.
AWAITING_PERMISSIONsend to a tab whose engine shows an approval or question menu (the tab's row reads ?); a paste would land in the menu and Enter would confirm its highlighted option. Answer it with keys, then send. tabIds names the tabs.
ENGINE_PROBE_FAILEDThe ps probe behind that check failed or blew its deadline, so the tab's engine was never read.
DISPATCHER_UNREACHABLEA bare send whose dispatcher tab is dead and whose task has no live engine.
NOT_DELIVEREDThe task was created but the prompt never reached its engine.
EMPTY_SUCCESS_REPORTA succeeded: send, or report --outcome done on a send delivery, from a branch with 0 commits; commit, or pass --allow-empty.
SESSION_FAILEDA hosted engine session could not be started or written to.
ENGINE_LIMITStarting a session would pass engine.maxLiveSessions; nothing was started.
ENGINE_ADMISSION_UNAVAILABLEThe running PTY host predates atomic admission; update and restart it before a capped launch.
ENGINE_ADMISSION_BUSYThe session inventory kept changing during admission; nothing was started, so retry the returned command.
BAD_EFFORTThe task's engine declares no effort levels, or not that one.
BAD_MODELThe task's engine declares no model flag, so a model cannot be passed to it.
CONFLICTING_FLAGSadd --tier beside --command, --model, --effort or --agents — the tier already fills those. add --parent beside --no-parent; --no-worktree / --isolate-in beside --no-parent, each other, or a flag that names a repo or fork point of their own.
EMPTY_SESSIONadd with nothing to run (no --prompt, --prompt-file, --start, --count, --agents or --worktree-only), from any caller: it would leave a task with no agent.
NOT_A_GIT_REPOadd under a dir task that is not inside a git repository (add --isolate-in too).
NOT_A_GIT_REPOupdate --project PATH where PATH is not inside a git repository.
SAME_PROJECTupdate --project names the project the task already belongs to.
CHILD_NOT_MOVABLEupdate --project on a child task: move its parent instead (children follow), or update --parent none first.
REMOTE_MOVE_UNSUPPORTEDupdate --project where the task or the target project lives on another machine.
NOT_A_LINKED_REPOadd --isolate-in names something that is not a top-level symlink to another git checkout.
REMOTE_PARENT_UNSUPPORTEDadd under a task that lives on another machine's project, or update --parent with either task on one.
PARENT_NOT_FOUNDupdate --parent or add --parent names no task on the task's own machine.
MISSING_TARGETadd --no-worktree or --isolate-in with no --parent and no verified calling session.
PARENT_IS_SELFupdate --parent names the task itself.
PARENT_CYCLEupdate --parent names one of the task's own descendants.
PARENT_IN_GROUPupdate --group G --parent ID where ID is itself a task of round G.
TASK_NOT_MOVABLEupdate --parent on a project's main task or a routine session.
TIER_UNAVAILABLEAuto routing is not configured, or the tier's target cannot start (engine not listed, not logged in, model/effort its engine cannot carry).
PARTIAL_FANOUTA parallel round with at least one failure (exit 3).
SKIPPEDshould-run or send --if-runnable refused the tick (exit 3). The verdict is the stdout payload, not an error envelope.
UNSUPPORTEDinterrupt on an engine that never declared how it is interrupted.
WATCH_TIMEOUTwatch reached --timeout before any --until state; nothing has happened YET.
DAEMON_GONEThe daemon died mid-watch; the verb never reconnects silently.
GITHUB_SIGNED_OUTgithub-sync or github-logout on a machine whose gh has no github.com login.
GITHUB_GH_MISSINGA github-* verb on a machine without gh.
GITHUB_LOGIN_FAILEDThe device flow printed no code, gh exited without signing in, or gh refused the token github-apply was given.
GITHUB_LOGIN_CANCELLEDA pending github-login was cancelled before its code appeared.
GITHUB_LOGOUT_FAILEDgh auth logout failed.
GITHUB_SYNC_FORWARDEDgithub-sync sent by another machine's hub: an account is only shared from the machine that owns it.
GITHUB_APPLY_FORWARDED_ONLYgithub-apply called directly. Only another machine's github-sync may send it.

DELIVER_FAILED and CREATE_FAILED are not error codes in that sense: they only ever appear inside a PARTIAL_FANOUT payload, on failures[].error.code, naming which stage lost that one sibling. A caller reads them from stdout.

Flag parsing: --key value and --key=value both work; boolean flags may be given bare (--force ⇒ true) or explicitly (--pinned=false); --tab / enum / positive-int values are validated against the verb's spec, and unknown flags are rejected (exit 2). --repo resolves relative paths against $PWD (~ expanded). spawn-task is an alias of add.

Engines are chosen by COMMAND, not by a vendor enum: --command takes an engine id from engine-list (claude, codex, copilot, kimi, pi, omp, bob, the shipped contrib engines whose CLI is installed — gemini, opencode, cursor, grok, droid, amp, devin, qodercli, cline, kiro, maki, antigravity, hermes, kilo, mastracode — plus any engine you registered) or a full command line Rove runs verbatim (--command "codex --search"). Nothing validates an engine's flags, so probe an unfamiliar one with <cmd> --help before dispatching. See ENGINES.md for how the protocol Rove speaks to a command is derived from it.

Removed verbs have no aliases: fan-out → add --count N, the per-field task edits (set-vendor, rename, set-branch, set-command, set-effort, set-model, set-status, pin) → update, issue-set-status → issue-update --status, and routine-set-enabled → routine-update --enabled. Calling any of them returns UNKNOWN_VERB with the replacement in nextCommandArgs.

discover

  • schema (offline): the API, as JSON. Default is a compact index (groups + verb summaries, no flags); drill in with --verb <name> (full flag detail for one verb), --group <g>, or --all (everything; large). Includes an apiVersion agents can gate on.
  • engine-list (offline): every engine Rove can launch — the same set the TUI's engine pickers offer: built-ins, your registered presets, the shipped contrib engines whose CLI is on PATH, and engines contributed by enabled plugins — each with the RAW command it runs, its display name, its protocol (the adapter Rove speaks to it: history reads, trust pre-answer, first-message delivery; generic = none), and its models — what the engine can name for --model (suggestions, not a closed set; null = Rove cannot list them for this engine). A plugin engine reports its own id as its protocol — the plugin's manifest carries the screen rules and identity Rove drives it with, so it is not generic. What it prints is what a launch runs, so an entry can be copied into --command verbatim or edited first. --status probes each engine on this machine and adds installed (its CLI is there) and ready (installed, and signed in where Rove can read the login); with --machine it answers for that machine. Returns { engines }.
  • repo-match (--origin URL | --root SHA) [--allow-fork]: this machine's checkout of a repo, matched by identity: --origin (any git remote form, compared normalized as host/owner/name) or --root (its first commit). Searches the saved repos and every task's repo. With several checkouts the primary one wins (rove machine primary), else a clean one, else the first. A checkout with the same root but another origin is a fork and matches only with --allow-fork. Returns { repo, fork?, checkouts, searched }: repo is null when nothing matched, fork names a fork that was skipped, checkouts every match. A hub asks a machine this before forwarding add --repo.
  • repo-list: this machine's repos by id (host/owner/name, or root:<sha> for a repo with no origin), each with its checkouts and its primary one. Returns { repos: [{ id, primary, checkouts }] }. rove api repo-list --machine <alias> answers for another machine.

read

  • list: list all tasks. Returns { tasks, activeTaskId } — activeTaskId is the shared focus that verbs using the implicit target (send, pane-open, pane-close, read-output without --task-id) default to; null means no active task. Reading it back is the audit trail for any delivery that omitted --task-id. A multi-repo task also carries .repoCount (all repos, primary included); one-repo tasks omit it. .prStatus is the primary's PR; each extra's is on get-task's .task.extraRepos[].prStatus (Rove polls every repo against its own worktree, branch and remote), and the sidebar chip, context and collect show one rolled-up status — any failing check wins, then pending, then open, then merged.

    • --repo PATH, --status S1,S2 and --activity A1,A2 narrow the list. Flags combine with AND; a comma list matches any of its values. rove api list --activity permission_needed,error is every task waiting on you. With --activity, each returned task carries the .activity it matched (the same view collect reports); a task whose engine state Rove cannot read, such as one that never started or one on another machine, never matches. --repo never matches another machine's task, and unresolvableRepos beside tasks names task repos it could not compare. An unknown status or state is refused with BAD_FLAG.
    • Archived tasks (archive) are left out; --archived lists only them, and combines with the other flags the same way.
    • --fields F1,F2 keeps only those keys on each task row, so a large fleet reads short: rove api list --fields id,title,status,branch. Any task key is accepted, plus repoCount and activity; a key a task does not carry stays absent. Filters run first, so --activity running --fields id,activity works. activeTaskId and unresolvableRepos are untouched. A misspelled key is refused with BAD_FLAG, and the message lists the valid ones.
  • get-task --task-id <id>: one task's metadata; .running = any of its hosted engine tabs is live (not just the first); .tabs = the task's terminal tabs (id/kind/title/vendor/liveVendor/lastTitle/ autoTitle/sessionId + per-tab alive/engineAlive/liveTitle): the discovery read for send --tab tab-N. liveTitle is the live session's own window title (the engine's name for the conversation), null when it is dead or set none; lastTitle is only recorded while a TUI has the tab open. sessionId is the conversation the tab pinned — the exact id claude --resume <id> (or the engine's own resume verb) reopens, and the one send --tab tab-N --respawn brings back. Present on engine tabs that recorded one, dead tabs included; absent for engines that mint their own id late and for tabs that never spawned. liveVendor on a live tab is a fresh foreground process walk (which engine actually runs in the tab's shell right now. A hand-typed claude in a shell tab counts, a ctrl+C'd engine doesn't); dead tabs report the last recorded value. alive and engineAlive answer different questions and a fleet reader needs both: alive is the tab's hosted PTY SESSION, engineAlive is whether an ENGINE PROCESS is running inside that session's tree. keepAlive execs a login shell where an engine exits, so alive: true, engineAlive: false is a tab holding a bare zsh prompt — the per-tab form of the session-outlives-its-engine hazard described under collect's .running below, and the field that settles it in one read instead of a collect hop or your own ps walk. Both are three-valued: null means nothing walked the tab (a ps that failed, a pty host that could not be asked), which is "couldn't look" and never a verdict. Never read null as false — the same rule .running states, for the same reason. A dead tab whose session ended abnormally also carries exit (code/signal/at); clean exits stay exit: null. A live PTY session the persisted snapshot does not list still gets a row, marked unregistered: true; an alive engine is never invisible here. .task.dispatcher ({taskId, tabId}) = the Rove session that created the task, when one did: the lineage read for a parallel round's parent. .task.command = the raw launch command pinned on the task; .task.vendor = the protocol derived from it. .task.prompt = the full text of the prompt add --prompt delivered into the task's engine (verbatim, never truncated), recorded at delivery time — it is the durable copy of the task brief, surviving a dead engine or lost context where the engine transcript does not; absent when the task was created without a prompt or the paste never landed. .task.baseRef = the branch the task was cut from (add --base-branch), the fork point collect measures against. .task.prStatus = the branch's PR as the daemon last polled it (lifecycle, number, url, lastCheckedAt) — including .prStatus.checkState: none (no PR) / pending / passing / failing / unknown. This is Rove's CI truth for the branch, and passing is what "CI is green" means; a local test run is a different claim. A cancelled run counts as neither: the other checks decide, and a PR whose only runs were cancelled reads none. Absent until the poller has seen a PR for the branch. runnability is the should-run verdict for the whole task, so a dashboard shows the answer a scheduler would act on.

  • collect [--task-ids a,b,c] [--group GROUPID] [--repo PATH]: read-only health snapshot of a parallel round — the one read that answers "what is this round's status right now" without fanning out to get-task per task. Select the tasks by fan-out round (--group, the groupId that add --count returns), by repo, or by explicit ids. Each task also carries runnability (the should-run verdict). For a task with extra repos each entry also carries repos[] — { name, branch, worktreePath, changes, base } per repo, primary first; the top-level changes and base stay the primary's, and one-repo tasks have no repos key.

    With --repo, a repo path that no longer resolves to a readable git repository is reported, never filtered away: the target failing to resolve is a REPO_UNRESOLVABLE error naming the path, and a task whose own repo will not resolve is listed in unresolvableRepos beside tasks. An empty tasks list is only "this round is empty" when unresolvableRepos is absent — the same null-versus-empty distinction pty-list and discover-adoptable keep.

    Per task: identity, branch, lineage (.dispatcher, .groupId), plus

    • .running — true / false / null. Is an ENGINE PROCESS alive in any of the task's engine tabs: the pty host's session inventory joined with a live ps walk of each session's tree. Both halves are load bearing. A session outlives its engine (keepAlive drops the tab into a login shell), so the inventory alone reported a task as running for hours after its engine was reaped. And null means the pty host could not be asked at all — "couldn't look", never "nothing is running", the same distinction pty-list publishes as sessions: null. Never treat null as false: a cleanup loop that does will delete worktrees holding live work. Still process truth, not the cached status field list reports, which lags and will happily call a working fleet idle.
    • .activity — {state, at, forMs, detail?, source?} from the daemon's activity registry: the engine's state and how long it has been in it. States: idle and turn_complete are both at rest (a precheck asking "is it free?" must accept either); running; permission_needed; rate_limited (clears on its own); error (the last turn failed, .detail.note carries the engine's text); dead (the engine process exited). An engine with no failure hook (codex) can die on its first turn without telling the daemon; when its own error row is at the bottom of its screen, the state reads error with source: "screen" instead of idle. forMs is the "stuck for 40 minutes" number. null when the registry cannot answer (daemon restarted, task never observed) — an honest unknown, never a fabricated idle. .activity.state disagreeing with .running is itself the signal: running: false with a permission_needed state is a worker that died at a prompt. The daemon observes this without an attached TUI, on a slower cadence (~60s) than it uses for one.
    • .tabs — the same per-tab join as get-task (pick a send --tab target without a second hop). A tab whose session died abnormally carries .exit with code/signal/at/layer and tail, the last lines the session printed, so a crash comes back with its cause attached. The tail rides along only when the durable record describes that same death. layer says WHICH process the record describes, without which code and signal cannot be read together. "pty" is the tab's own session child, on a dead tab: a signalled session has no wait-status code, so code then comes from the wrapper's own Engine exited (code N) banner and belongs to the ENGINE, while signal belongs to the session that outlived it. "engine" is the AI process gone from a tab whose SESSION IS STILL ALIVE (alive: true, engineAlive: false — the login shell the tab keeps in its place); it is reported for a clean engine exit too, because code: 0 ("the human quit their agent") and code: 143 ("it was SIGTERMed and there is unfinished work") are exactly what a fleet reader is trying to tell apart. atApproximate: true on such a row means at is when the daemon DISCOVERED the death, not when it happened — see the inspect sessionExits note below.
    • .changes — uncommitted files (added/deleted). Non-zero means the task cannot land as-is, however it reported itself.
    • .base — committed work: ahead (commits vs the base branch) and a diffstat. ahead: 0 against a resolved baseRef is the "reported succeeded, committed nothing" tell. baseRef is the task's RECORDED fork point (add --base-branch) when it has one — tasks cut from release/2.x are measured against release/2.x, not against a guessed main; only records predating the field (or a recorded ref that no longer resolves) fall back to the origin/HEAD → main → master guess.

    Read-only by contract: it starts no engines, writes nothing, and changes no task state.

  • context --repo PATH [--limit N] [--text]: the coordinator's orientation read. One composed snapshot of a project, meant to be run at the start of a session and whenever the agent lacks the state it needs, so a coordinating agent works from what the command printed instead of from what it remembers. A turn started by a message that already carries the outcome acts on that message. Composition only — five reads the daemon already answers, joined server-side.

    The point is the derived group. A task's status is a claim somebody wrote (update --status), so a worker that crashed stays in_progress forever. context reports instead which group a task is in, derived from its report, its PR observation, its arbitrated engine activity and its tab liveness:

    groupmeansyour move
    waiting-on-youpermission prompt, a quota wall with no scheduled resume, a settled error, a dead tab that delivered nothing, a failed deletionanswer it
    landingPR open and approvedmerge it
    ready-for-reviewa report exists, or a turn finished, and nobody actedread the diff
    workingan engine is producing output, or the daemon will resume it on a timernothing
    idlewe looked; nothing is happeningnothing
    unknownwe could not looklook (get-task, read-output)

    rank is the sort key (0 = waiting-on-you), and tasks comes back sorted by it, so the first row is what needs a person next. Ties break on the freshest activity. unknown is a sixth group on purpose: absence of a signal is never a verdict, so a task the activity registry cannot answer for is NOT reported idle — the same null ≠ false rule the rest of this page keeps.

    Two debounces keep the top group honest, both derived from the activity observer's own cadences: an error must stand 20s (an engine that fails a turn and retries by itself fires turn-failed then turn-start seconds apart), and a dead/not-alive tab 60s (one foreground-walk sample — the only thing that can see an engine die inside a live PTY). permission_needed and rate_limited are debounced by zero: they are engine hook events, not screen reads, and a human is the only thing that clears them.

    Per task: taskId, title, branch, group, rank, activity ({state, forMs}, null when unreadable), checkState (Rove's own CI truth, when there is a PR), pr, and the worker's report claim. Nothing else — the verb is paid for on every coordinator turn, and worktreePath, vendor, groupId and the declared status are one get-task or collect hop away.

    Beside the tasks: attention — the unhandled attention-inbox episodes (routine episodes included; their subject is a schedule, so no repo filter can scope them) — and notes, the repo's newest 15 field notes, which are exactly the ones injected into a fresh session here, so a coordinator briefs its workers from what those workers will read.

    Only worktree tasks are listed: the repo's main seat and dir entries are not anybody's turn, and a worktree already being removed is spent (a deletion that failed is listed, as waiting-on-you). --limit defaults to 20 and drops the quiet tail, reporting omittedTasks. Repo resolution follows collect. A side read that fails degrades to an empty section rather than failing the verb.

    --text returns { text } — one compact line per task in rank order, for an agent that would rather read it than parse it. It replaces the structured rows; ask for one or the other, not both.

  • digest --repo PATH [--since-days N]: the repo's recent agent work, tasks touched in the window (a visit that only focuses a task no longer counts) plus routine outcomes by status. Default window 7 days. Repo resolution follows collect: an unresolvable --repo is a REPO_UNRESOLVABLE error, and unresolvable task repos come back in unresolvableRepos rather than being counted as absent. Task outcomes are deliberately absent: completion travels to the spawning agent's engine tab (send), not into Rove state.

  • agent-turns [--task-id ID] [--repo PATH] [--since-days N] [--limit N]: per-turn agent telemetry, one record per completed engine turn (taskId/tabId/vendor/model/sessionId/startedAt/endedAt/ usage), newest first, plus a totals roll-up (token sums, summed wall-clock, turn counts per model). Default window 7 days, 200 records. Records are produced by each engine's own adapter from that vendor's transcript and stored by the daemon on turn-complete. Claude and codex have a turn reader; every other engine has none, so a task on one yields an empty page because nothing can read its turns, not because it did no work. Read-only.

  • pty-list (offline): hosted PTY sessions (key, alive, pid, command, live window title, optional generation identifying the host's current in-memory session lifetime). sessions: [] means a live PTY host with nothing running; sessions: null means there was no host to ask — "couldn't look", not an idle fleet. Never read null as "no sessions running".

  • read-output [--task-id ID] [--tab TAB] [--source auto|history|terminal] [--cursor C] [--limit N]: a task's engine output as bounded, cursor-paged JSON: structured history when the engine has it, else a labeled terminal tail (fallbackReason). Read-only: it never attaches to a session or types into one. Without --tab it reads the task's own conversation, never a sibling's in a shared directory; pass --tab (ids from get-task .tabs[]) to read one specific session.

    The first page is the NEWEST: the last --limit messages (history; default 40, max 50) or the last --limit terminal lines (terminal; default 40, max 200). history.firstIndex is the transcript index of messages[0]. cursor is the forward cursor; once you are caught up it is the poll point — re-read with it to get only what the session wrote since. olderCursor pages backward through history (null at the start of the transcript, and always null for terminal). A cursor is pinned to one source/session/tab and returns SOURCE_CHANGED if that moved.

    --tab tab-N reads that tab's own pinned conversation (structured history) when it is an engine tab with a pinned session id (claude tabs); otherwise that tab's terminal — with fallbackReason history_missing or engine_unsupported for an engine tab with no readable history (including a pinned conversation with no messages yet, such as a fresh tab still on a trust or login prompt; --source history returns its empty page), and null for shell/command tabs. --source history on a non-engine tab is HISTORY_REQUIRED; TAB_NOT_FOUND when the tab has no session. A dead session's terminal page includes terminal.exit (code/signal/at) while the PTY host still runs. A history page also carries engineError when the engine's screen ends on its own error row: a turn that failed before replying leaves nothing in the transcript.

    Following a running session. Pair watch with the poll point instead of re-reading everything:

    rove api read-output --task-id X                       # newest page; note .cursor
    rove api watch --task-ids X --until turn_complete      # block until it moves
    rove api read-output --task-id X --cursor <cursor>     # only what was written since

    Repeat the last two steps (each response's cursor is the next poll point). The CLI never attaches to the session; this is how an agent follows one without taking it over.

  • search --query TEXT [--repo PATH] [--limit N]: find TEXT, case-insensitively, in what the user and the agents wrote in every task's engine conversations, newest session first. Tool calls and their output are not searched. Returns {query, matches:[{taskId, title, tabId?, role, at, snippet}], skipped:[{taskId, reason}], truncated}. reason is other-machine (the conversation lives there) or engine-unsupported (no history reader, e.g. kimi or a custom engine). At most 3 matches per session, --limit (default 20, max 100) in all. Transcript files are prefiltered with rg when it is on PATH (an in-process scan otherwise), then only matching sessions are parsed; past about 1.5 s of parsing it stops and sets truncated: true. A session is credited to the task whose tab pinned it; an unpinned one only when no other task works in that directory.

  • watch (--task-ids a,b,c | --group GROUPID) --until STATE[,STATE] [--timeout MS]: block until a watched task's engine reaches one of --until's states, streaming every transition on the way. This is the push-driven replacement for a collect polling loop: the daemon already publishes each activity transition, so a supervisor no longer pays a process spawn and a socket per tick to find out nothing changed.

    Valid states: idle, running, turn_complete, rate_limited, permission_needed, error, dead. A state that does not exist is refused up front with BAD_FLAG — for a verb whose whole job is to wait, a typo that waits forever is the worst possible failure.

    dead is the one polling is worst at. A SIGKILLed engine fires no hook, so the daemon writes dead from the PTY host's exit record and pushes it here immediately, instead of it surfacing whenever the next poll happened to land.

    Output is a stream, then a result. One NDJSON line per transition — { taskId, tabId?, state, at } — followed by the usual single result object when the watch ends. Reading line-by-line lets a caller act on the first line; reading the whole output gets both. The channel replays its current value on subscribe, so a task that is ALREADY in an --until state matches immediately, which is the intended answer.

    Exit codes: 0 with matched on a hit; WATCH_TIMEOUT when --timeout (default 300000 ms) elapses first — that says nothing has happened yet, never that nothing will; DAEMON_GONE when the daemon dies mid-watch. The verb never reconnects on its own: reconnecting silently would hide the gap in which transitions were missed.

  • inspect [--task-id ID] (offline): diagnostics in one read, across four sections: daemon (raw per-task/per-tab activity entries, plus contextUsage — the collector's current reading per live engine session, keyed taskId::tabId, carrying contextTokens and the session's inputTokens / outputTokens / cacheReadTokens / cacheCreationTokens where the engine reports them; this is the only read that shows those totals), sessions (PTY inventory joined with a live process-tree walk; dead sessions carry exit), sessionExits (durable death records, newest first: exit code/signal/at plus a plain-text output tail, kept in pty-exits.json so they survive the PTY host's idle-exit. layer: "pty" is the terminal process, abnormal exits only; layer: "engine" is the AI process gone from a still-running terminal, and adds parentAlive plus vendor where a walk named the engine. An engine that died while no daemon was watching — the daemon idle-exits on its last GUI — is reconciled at the next daemon start from the wrapper's Engine exited (code N) banner still in the session's ring; those records carry atApproximate: true, meaning at is discovery time and no vendor, because nothing on disk records either), and tabs (the snapshots the sidebar names its rows from, reconciled against the live session inventory: a task whose snapshot is missing an alive <taskId>::tab-N session reports those tab ids under unregistered, and a task with live sessions but no snapshot at all still gets an entry). Non-spawning: a missing daemon or PTY host degrades that section to null. Run and paste this first when reporting a badge, label, engine-identity, or engine-crash bug.

  • ports (--task-id ID | --all): the TCP ports a task's processes listen on, on this machine — the dev server, preview or debugger an agent started, without scanning by hand. A listener belongs to the task whose checkout (primary or extra repo) holds its process's cwd; when checkouts nest, the deepest wins. Returns { taskId, ports }, each row { port, host, url, pid, command, cwd }; url is http://localhost:<port> for a wildcard or loopback bind. --all lists every listener, each with its owning taskId (null = no task). ports: null means the scan could not run (Linux reads /proc, macOS runs lsof), never that nothing is listening.

  • viewers [--task-id ID]: every UI attached and showing a task right now, so an agent knows what the human can see before it drives anything. Returns { viewers: [{ viewer, surface, taskId, tabId?, panel, panelOpen?, narrow?, local }] }. surface is tui, web (a browser page) or phone; panel is true only for a web page with a side panel (desktop width); panelOpen and narrow are what the page reported; local is true when the screen is on the daemon's machine (a TUI always; a page the bridge saw arrive over a tunnel is not). An empty list means nobody is looking. --task-id keeps one task's viewers. A screen that shows no task, or has disconnected, is not listed.

  • worktree-files --task-id ID: the task's worktree files, tracked plus untracked-not-ignored, worktree-relative. Returns { files }. Runs where the worktree lives, so it reads a task on another registered machine too.

  • worktree-file --task-id ID --path PATH: one previewable file of the task's worktree for a client to render: markdown, HTML, SVG, PNG/JPEG/GIF/WebP or PDF, at most 5 MB. Returns { path, mime, size, base64 }. PATH is worktree-relative and its real path (symlinks resolved) must stay inside the worktree (BAD_ARGS otherwise); another file type is NOT_PREVIEWABLE, a larger file TOO_LARGE. Runs where the worktree lives, so it reads a task on another registered machine too.

  • worktree-diff --task-id ID: the task's changed files, committed on its branch against the base (scope: branch, the PR base first) and then uncommitted (scope: working). Returns { base, files[{ path, status, added, deleted, scope }] }; base is null when none resolves.

  • worktree-diff-file --task-id ID --path PATH --scope branch|working|task: one changed file's preview, the Files pane's data (diff hunks, or the file for a code or binary view). working is the uncommitted diff, branch the commits since the base, task both together with untracked files (the whole task against its base; with --path ., one diff of everything). --ignore-whitespace runs the diff as git diff -w, so whitespace-only changes drop out. --base REF (with --scope task only) compares against that branch, tag or commit instead of the task's base (still measured from the merge-base with it, as a PR is); a ref that is not a commit in the repository is refused as BAD_REF. PATH is worktree-relative; absolute or .. paths are refused.

  • worktree-refs --task-id ID: what a diff can be compared against: the repository's local branches, remote branches and tags, and the worktree branch's recent commits. Returns { branches, remotes, tags, commits[{ sha, subject }] }. Runs where the worktree lives, so it reads a task on another registered machine too.

  • review-comments --task-id ID: the unresolved review threads on the task's PR, so an agent can address review feedback by file and line. Returns { taskId, prNumber, threads, totalUnresolved }; each thread is { path, line, outdated, comments: [{ author, body, url }] }, in GitHub's order, at most 50 (totalUnresolved counts them all) with each comment cut at 4000 characters. All thread pages are counted, and every comment in the returned threads is included in conversation order. line is the line in the current diff, the original line once the code has changed (outdated: true), or null for a file-level comment. prNumber: null means the task has no PR yet. The daemon asks GitHub through gh in the task's primary worktree; unavailable: { reason, detail } means it could not (gh missing or signed out), never that no thread is open. Each GitHub subprocess has a 10-second budget; pagination is exempt from the client's 20-second RPC deadline.

  • deliveries --task-id ID [--limit N]: the task's armed deliveries, newest first; the daemon keeps the newest 50 per task. A delivery is one prompt that asked for a report (see report). Each entry has id (d-<n>), source (routine, send, gate-answer), tabId, promptHead (first 200 characters), at, and status: awaiting, reported, missing or skipped. A reported entry carries report ({ outcome, summary, evidence[], next?, at }). A missing one carries missing ({ cause, note?, at }); cause is turn-complete (the turn ended with no report), turn-failed (note is the failure detail), interrupted (the turn was interrupted), session-end (the engine session ended), engine-dead (note is the last line of the tab's output), rate-limited (the engine hit its quota; quota-resume continues the turn and a late report still lands), transient-failure (the connection dropped or the provider 5xx'd; note is the error), or stalled (awaiting for over 10 minutes while the tab was neither running nor at a permission prompt; note is tab state: <state>). A permission prompt does not mark a delivery missing.

    A tab runs its prompts in order, so a turn event settles the OLDEST matching delivery: a turn-start begins the oldest one whose turn has not started, and a turn end (turn-complete, interrupted, session-end) ends the oldest one whose turn has started. A Stop that arrives before any turn started belongs to an earlier prompt the paste queued behind. A failed turn ends the oldest started delivery, else the oldest awaiting one; an engine exit ends all of the tab's. A missing delivery raises a report_missing Inbox row (none for rate-limited: the engine's own rate-limit row covers it), keyed by the delivery so the tab's next turn does not clear it. A late report or dismissing the row clears it. A routine delivery whose turn completes unreported is quiet instead (nothing to report): no Inbox row, and it does not hold the next run. A routine turn that reported or went quiet raises no turn_complete Inbox row. A loop prompt can read deliveries --limit 5 instead of a growing notes file.

  • should-run --task-id ID [--tab TAB]: would a tick sent to this task be used? Prints { run: true } and exits 0, or { run: false, reason, detail?, until?, gateIds? } and exits 3 (the verdict is on stdout in both cases). The first matching reason wins, in this order: task-missing; awaiting-owner (an open gate; gateIds); report-missing (the tab's newest delivery ended unreported and its Inbox row is neither dismissed nor answered by a late report; skipped ticks and a delivery the rate limit or a transient-failure ended do not count: the next tick is the retry); quota (rate_limited; until is the reset time, from the armed quota-resume or else the usage cache, and the block ends there; with no known reset the quota is not a reason to skip); engine-error (a sticky error, billing and auth included; a transient failure is not one); engine-dead (an exact --tab whose engine exited); awaiting-permission; busy (a turn is running, the newest delivery still awaits its report, or a send --if-runnable is mid-paste). A task whose only gate is answered but undelivered is runnable: the next tick carries the answer. Without --tab the whole task is judged (any running tab is busy) and a dead engine is not a skip, since send revives it; --tab new judges only task-level state. Reasons that need a person clear when they act: answering or withdrawing the gate, a late report or dismissing the report_missing row (TUI, bridge or weixin), the engine's next turn-start. The daemon's routine runner and the quota-resume timer ask the same question. get-task and collect carry the same verdict as runnability.

create

  • add --repo PATH|REPO [--repo PATH|REPO …] [--clone] [--allow-fork] [--fork TASK_ID] [--workspace layerfs] [--workspace-on MACHINE] [--engine-there] [--parent ID | --no-parent] [--no-worktree | --isolate-in LINK] [--title T] [--branch B] [--base-branch B] [--base-for REPO=REF …] [--worktree-name NAME] [--command CMD] [--count N | --agents claude:2,codex:1] [--status S] [--pin] [--activate] [--require-report] [--prompt TEXT | --prompt-file PATH | --start | --worktree-only]: create a task (appears in the sidebar immediately). With --prompt it also materializes the worktree, starts the engine, and delivers the prompt. With --start and no prompt the engine starts idle in the task's own tab-1 (started: true, tabId, session); a refusal such as ENGINE_LIMIT keeps the task and names it in taskId. A --prompt start that fails because the disk is full (git's No space left on device) removes the task it had just created: nothing was cut or started, so the same add works once there is space. --start beside --prompt, --count or --agents is CONFLICTING_FLAGS. A bare add is refused for every caller (EMPTY_SESSION): add sends an agent to do something, so say what — --prompt for work, --start for an idle engine a person will type into, or --worktree-only for a checkout that is not a session. With --worktree-only the worktree is cut at once and no engine starts (worktreePath, started: false): a separate checkout for the calling session to work in. It is not a session, so the sidebar and the web rail do not list it, nested or top level, until an engine starts a turn in it; list and the Worktrees page still show it, and deleting it removes the worktree. It conflicts with --prompt, --start, --count, --agents and --no-worktree. Does not steal focus unless --activate. Alias: spawn-task. The result's .engine ({vendor, command, model, effort}, per row for --count/--agents) is what the task actually launches after the flags and the repo's default engine are resolved. .warnings names a --model that plainly belongs to another vendor (codex handed a claude-* id); the task is still created, since a wrapper command can point an engine at a gateway that serves foreign models. Without --branch, the branch name is auto-derived from the title following the repo's own branch-naming convention (inferred from its existing local + origin branches, e.g. feat/login-flow in a type-prefixed repo, login-flow in a bare-slug or empty repo; name collisions get a short -2/-3 suffix, and a name that clashes with an existing branch's folder is skipped or flattened). A title that kebab-cases to nothing — written in a non-Latin script, or all emoji / punctuation — falls back to task-<last 6 of the task id>, so two such tasks get two distinct names instead of task and task-2. An explicit --branch is checked against git check-ref-format --branch BEFORE the task is created; a name git would refuse is INVALID_BRANCH and nothing is written.

    --fork TASK_ID makes the new task a sibling in that task's repo whose worktree is a copy-on-write clone of the original's, so it starts with the original's uncommitted edits, staged changes, untracked files and gitignored dirs, on a new branch rooted at the original's HEAD (.task.baseRef; .task.workspace is {"backend":"cow-clone","ref":"<original id>"}). The clone is made during the call and the original is never modified. It replaces --repo, and --repo, --base-branch, --base-for, --count and --agents conflict with it (CONFLICTING_FLAGS). The title defaults to <original title> (fork), not the prompt's first line. Refused with FORK_UNSUPPORTED, nothing created, for a main or directory task, a multi-repo or remote task, or a worktree on a volume that cannot clone files; see Fork a task with its uncommitted work.

    add --machine <machine> is add --workspace-on <machine> unless --engine-there is passed or the request is one --workspace-on cannot serve (the conflicts below, or a Windows machine); those run in that machine's daemon.

    --workspace-on <machine> runs the agent here and keeps its files and commands on that registered machine: it cuts the worktree (--repo, --clone, --allow-fork, --branch and --base-branch apply there), and the engine starts on this machine in a link directory (.task.workspace is {"backend":"remote"}). The reply adds machine, remoteTaskId and remotePath. See Run the agent here, keep the work there. It conflicts with --count, --agents, --fork, --workspace, --no-worktree, --isolate-in, --worktree-only, --tier and a second --repo (CONFLICTING_FLAGS). A Windows machine, which rx cannot reach, is refused before it cuts anything (UNSUPPORTED).

    --workspace layerfs (experimental, Linux only) puts the task's files in a LayerFS Workspace inside a Docker sandbox of its own instead of a git worktree (.task.workspace is {"backend":"layerfs"}); see LayerFS tasks. Refused before any task is written with LAYERFS_UNAVAILABLE (not Linux, or experimental.layerfs unset or incomplete) or LAYERFS_NEEDS_ROOT (sudo -n cannot run). --fork, --count, --agents, --no-worktree, --isolate-in and a second --repo conflict with it (CONFLICTING_FLAGS). Inside a session it still nests under the caller (task.create with parentTaskId), never a worktree cut from the parent's tip.

    --repo is resolved to the repository root, so a path pointing at a SUBDIRECTORY is accepted and climbs. When it does, the result carries repoResolvedFrom with the path you passed — absent when --repo already named the root.

    --repo also takes a repo id: owner/name (GitHub), host/owner/name, or a remote URL. A value that could be a path (absolute, ~/.-led, or an existing directory) is a path. An id resolves to this machine's checkout of it, by the rules of repo-match. With no checkout it is NO_LOCAL_CHECKOUT, unless --clone is passed: then the repo is cloned into ~/.rove/repos/<host>/<owner>/<name> (reused if already there) and saved as a repo (CLONE_FAILED if git refuses). With --machine, the id resolves to that machine's checkout, and --allow-fork accepts a fork there (above).

    A --title is flattened to one line: newlines, tabs and other control characters collapse to single spaces (the sidebar row does not wrap, and a raw newline breaks its height).

    --worktree-name names the worktree DIRECTORY instead of taking one from the animal pool, so a caller can predict .task.worktreePath (<worktrees root>/<NAME>) rather than reading it back with get-task afterwards. It must be one path segment of letters, digits, ., _ or - and may not start with . (INVALID_WORKTREE_NAME); a name already in use in this repo — a live task, a directory on disk, or a concurrent create — is refused with WORKTREE_NAME_TAKEN and never silently suffixed -v2, because a caller that asked for probe-1 and quietly got probe-1-v2 looks in the wrong place and finds out somewhere else. Single task only, like --branch.

    --base-branch cuts the new branch from that ref instead of the repo's current HEAD and is persisted on the task (.task.baseRef) — the fork point collect measures against, durable across daemon restarts. The delivered --prompt text is persisted too (.task.prompt) — the durable copy of the task brief; it survives a dead engine or lost context, where the engine's own transcript does not.

    Multi-repo tasks. Repeat --repo (at most 8, all local) to make one task span several repos: the FIRST is the primary — the engine's cwd, the sidebar project, .task.repo / .branch / .worktreePath — and each further one becomes an entry of .task.extraRepos[] ({name, repo, branch, worktreePath, baseRef?}; name is the repo's directory name, -2-suffixed on a clash; the paths are empty until the worktrees exist). Extras never create a project row. Every repo gets its own worktree on the SAME branch name, created together on first enter and all-or-nothing: if any repo fails, the worktrees and fresh branches already cut are removed and the task stays unmaterialised. A repo where that branch name is already taken gets a -2 suffix there only (extraRepos[].branch records the real name); a branch named with --branch is reused as-is. --base-branch stays the primary's base; --base-for REPO=REF (repeatable; REPO is a name from extraRepos[].name or a path) sets another repo's. --count / --agents give every sibling the same repo set. The engine runs in the primary and sees the extras through its own add-dir flag where the engine declares one (claude, codex), $ROVE_TASK_REPOS (JSON [{name,path,branch,primary}]) plus $ROVE_REPO_<NAME> in its and its shell tabs' environment, and one line on the first prompt. delete covers every repo: one dirty gate over all worktrees (the refusal names each dirty repo), salvage refs written in the repo they came from, --delete-branch applied to each repo's branch. land and sync act on every repo (below, under lifecycle), and PR status is polled per repo. Refusals: TOO_MANY_REPOS, REPO_DUPLICATE (the same repo root twice, subdirectories resolve to their root), NOT_A_GIT_REPO (names the repo), REMOTE_EXTRA_UNSUPPORTED.

    Parallel attempts live here too: --count N spawns N sibling tasks of the SAME prompt, each with its own worktree and branch, sharing one groupId and #i/N titles; --agents claude:2,codex:1 does the same with a mixed fleet (engine IDS only; a raw command line can't be expressed per-sibling; issue N separate add --command calls for that). Capped at 10; prefer 3-4. Both require --prompt (a parallel round IS its prompt) and reject --branch (siblings cannot share one branch); --agents also rejects --count and --command (it already names each sibling's engine). This was the fan-out verb, which no longer exists.

    --command picks the engine (an id from engine-list, or a full command line). Omitted, the repo's default engine is used — skipping any engine switched off in Settings → Engines, the same as the TUI's picker. --effort LEVEL and --model MODEL pin the engine's reasoning level and model for the first session onward; both are gated per engine (BAD_EFFORT / BAD_MODEL), and --model is passed verbatim in the engine's own spelling. --tier swift|standard|deep fills all three from the auto-routing table (Settings → Auto routing) and records .task.tier; it is exclusive with --command/--model/--effort/--agents (CONFLICTING_FLAGS) and refuses a tier that cannot start (TIER_UNAVAILABLE).

    --require-report sets Task.requireReport: the task's own first --prompt and every later send to it arm a delivery, as if each carried --expect-report. For a task ticked unattended; change the setting with update --require-report on|off. add --expect-report arms the first prompt only. The reply carries deliveryId.

    --tier auto asks a classifier to read --prompt and pick the tier for you. It needs a prompt (BAD_FLAG without one) and it is off until you configure it — see autoRouting.classifier, which also spells out where the prompt text goes. It never fails a create: an unset classifier, a missing key, a timeout, an answer below the confidence threshold, or a tier this machine cannot start all create the task with the ordinary defaults and report what happened in .tierAuto.

    --repo accepts paths rove add refuses — a checkout under .scratch/, .dev-sandbox/ or $TMPDIR gets a task here and gets cannot be a project — inside a sandbox or scratch directory there. The two verbs ask different questions: rove add registers a PROJECT, and the eligibility gate exists to stop throwaway checkouts becoming permanent sidebar rows. add --repo creates a TASK, and the same gate still runs — it just skips minting the project row and the savedRepos entry instead of failing the call. So the task appears under a header derived from its own repo, and disappears with it.

A create issued from inside a Rove engine tab records the caller as the new task's dispatcher ({taskId, tabId} from $ROVE_TASK_ID/$ROVE_TAB_ID, with Rove aliases). That is the reply address the worker's bare send routes back to. Creates from a plain shell or the TUI record none.

Those env vars are verified, not trusted: an environment variable is inherited by every descendant process, so an agent's detached background process keeps exporting the ids of a tab it no longer runs in, and every task it creates would name a stranger's session as its dispatcher. Rove believes the pair only when <taskId>::<tabId> is a live session AND that session's shell is an ancestor of the calling process. When it isn't, the dispatcher / [ROVE PEER] provenance / spawner coda are all omitted (a wrong reply address delivers to someone else; no address at least fails visibly), and the verb's JSON result carries an identityWarning field saying so.

A new task's FIRST prompt (add --prompt, a parallel round, quick-fork) carries only facts about its own worktree; the standing worker instructions, naming its branch included, live in the Rove agent skill. Prompts into existing sessions (send, send --tab new, dispatch) are never modified.

Nesting (add makes children). A Task is one engine conversation; a task nested under another shows under it in the sidebar. Called from a verified rove session ($ROVE_TASK_ID / $ROVE_TAB_ID, verified as described above), add hangs the new task under the caller's top-level task (children never nest: added from a child, it becomes that child's sibling), while dispatcher still records the exact calling task and tab, so a bare send from it reports back to that session. An unverifiable env, a call from a plain shell, --machine, and tasks the daemon creates on its own (routines) stay top level, never an error. Only a task the sidebar would show in the repo you asked for is nested automatically: a call whose --repo is not the caller's tree's repo, or whose tree is a routine session, stays top level. --parent ID nests under that task whatever its repo (the rules of update --parent; PARENT_NOT_FOUND, REMOTE_PARENT_UNSUPPORTED), and --no-parent opts out; the two conflict (CONFLICTING_FLAGS). With --count / --agents every sibling gets the parent; with --fork X the parent is the caller, not X.

Where the child works. A nested task in the parent's own repo is cut from the parent's current branch tip instead of the repo's default base, unless --base-branch, --fork or several --repo name the fork point. A main checkout cuts from its HEAD; a dir task inside a git repo gets a worktree at the repo root and runs in the same relative subdirectory; a dir task outside any repo fails NOT_A_GIT_REPO. The branch tip is read when add runs, even though the checkout itself waits for the first prompt. --no-worktree shares the parent's directory instead, which suits parallel reading and review; two engines writing one tree collide. A shared child and --isolate-in need a parent (the caller or --parent, else MISSING_TARGET) and take its repo, so --repo, --fork, --base-for, and (for --no-worktree) --base-branch, --branch, --worktree-name, --count and --agents conflict with them.

Uncommitted changes are never copied into a new worktree: parentDirty: true in the reply says the parent had some. Top-level entries of the parent directory that are symlinks to a git checkout outside it are not isolated by a worktree of the root, because git copies the link; they come back as sharedLinks. --isolate-in LINK cuts the worktree in that linked repo instead (NOT_A_LINKED_REPO for a name that is not one). Both reply fields appear only for a child cut from its parent's tip or sharing its directory.

The TUI's and the web's "new conversation" gestures make shared children the same way: ctrl+t, ctrl+e with the tab destination, send --tab new and tab-new --kind engine start a task's first conversation in its own tab-1 and, once it holds one, each create a shared child, never a second engine tab. Deleting the parent promotes its children; see delete --cascade.

drive

  • send [--task-id ID] (--prompt TEXT | --prompt-file PATH) [--tab TAB] [--command CMD] [--handoff-from TAB [--replace]] [--plain] [--allow-empty] [--expect-report] [--if-runnable]: paste a follow-up into a task's running engine (one full turn). --prompt-file reads the text from a file (- = stdin) so the shell never sees it: backticks inside a double-quoted --prompt are command substitution, and a message that names a reply command (`rove api send …`) ships that command's OUTPUT instead of the words. add and dispatch take the same flag. Without --task-id, a task that has a dispatcher on record replies to that exact tab, falling back to the dispatcher task's live canonical engine tab when the tab died, and failing loud (DISPATCHER_UNREACHABLE) when nothing on that task is alive, never silently spawning a new engine. Otherwise the default is the active task and its canonical engine tab. From another Rove task, the message includes [ROVE PEER] provenance and a tab-precise reply command (--task-id <sender> --tab <sender's tab>); --plain skips that prefix, and a refused send's retry nextCommandArgs carry --plain so replaying them does not stack a second prefix. delivered: true says the bytes landed and nothing more: when the target was already in error or dead, the result carries targetState (and targetDetail), so a prompt sent into a failed session is visible as such. --tab new starts a new conversation: the target's own canonical engine while it has no tabs yet (a placeholder title becomes the prompt's), else a shared child task of the target (same directory, as add --no-worktree) that receives the prompt, titled from it, and the reply adds childTaskId (session, delivered and the other delivery facts describe the child; taskId stays the target), while --tab tab-N targets that exact tab (TAB_NOT_FOUND if it is dead or absent). A tab a pty-host restart froze is neither: it is listed by pty-list with its scrollback and launch command intact, and it refuses with TAB_RESTORED until you pass --respawn. --respawn (valid only with --tab tab-N) revives that tab in place and then delivers, replying respawned: true. A tab with a sessionId comes back on its own conversation (--resume <id>); a tab without one comes back on a fresh conversation; a tab Rove holds no snapshot for replays its frozen launch command, which for claude carries the task's original first prompt. That last case is why the flag is never implicit. The prompt you send is always pasted once the engine is up, never woven into the relaunch. When send STARTS a new session (started: true) while the task has freeze-restored engine tabs it did not use, the reply carries frozenTabs — {tab, sessionId} for each — because otherwise "opened a blank agent while your two real conversations are frozen" reports exactly like a healthy first start, and get-task then says running: true. --command CMD is valid only with --tab new: it runs that new child on that engine without changing the target's own. Using it with an existing tab is a BAD_FLAG error rather than a silent switch. --handoff-from tab-N (valid only with --tab new) continues that tab's conversation in the new one, e.g. hand stuck work from Claude to Codex: send --tab new --command codex --handoff-from tab-1. The first prompt becomes a handoff brief — the task title, the source engine's transcript path, the user's last request and a bounded excerpt (about 8 KB, oldest turns dropped first) of the latest turns, all marked as untrusted history — and --prompt, when given, follows it; without one the brief alone is sent, so --prompt is optional here. The new engine starts in the same directory and branch; the source tab is only read. NO_CONVERSATION = that tab has no conversation yet, NO_TRANSCRIPT = its engine keeps no transcript Rove can name, BAD_FLAG = not an engine tab, or no --tab new. --replace (needs --handoff-from) puts the new engine in that tab's own slot instead of a shared child task, for a source whose engine is dead or exited (get-task .tabs[]: alive: false, or engineAlive: false = only the fallback shell is left): same task, same tab id, same worktree, a fresh conversation on the brief; the reply adds replacedTab. A running engine is refused with TAB_ALIVE and keeps the child-task path. A replacement that fails to start or to receive the brief puts the tab back as it was, so the same call can be retried. Delivery needs a live engine in that tab: one that exited into the keep-alive shell refuses with ENGINE_NOT_RUNNING and a --tab new hint instead of pasting into a shell. When the ps probe behind that check fails or blows its deadline the refusal is ENGINE_PROBE_FAILED instead — the tab was never looked at, so nothing is claimed about its engine. Any registered engine passes, so a tab may run a different vendor than its task. A tab showing an approval or question menu (its row reads ?) refuses with AWAITING_PERMISSION: a paste is not a menu answer, and the Enter after it confirms whichever option is highlighted. Without --tab, any of the task's tabs at a menu refuses. Answer it with keys in the terminal; once a client has answered it, or the engine reports again, send goes through. A tab that came to rest straight from a menu (a stray turn-complete with no running turn between) counts as still at the menu until a turn runs or a client answers, the same rule --queue pastes under, and a tab that only just came to rest is read again ~1.5 s later, right before the paste. A tab mid-turn is not refused (the text joins the running turn; --queue waits for its end). Engines that report no permission prompts (no hook) are never refused. Without --tab, the canonical target is a live engine tab (tab-1 first, then any surviving engine tab); when live tabs exist but none resolves as an engine, send refuses with NO_ENGINE_TAB rather than silently spawning a duplicate engine. Only a task with no live session at all auto-starts its canonical engine tab, in the task's worktree. started: true in the result marks that fresh session (vs. delivery into an existing one).

    --expect-report arms a delivery. The reply carries deliveryId (d-<n>), and the pasted prompt ends with a footer line: [ROVE] When this turn ends, report: rove api report --delivery d-41 --outcome done|partial|blocked|failed --summary … [--evidence …]… [--next …]. The agent answers with report. A turn that ends without one marks the delivery missing and raises a report_missing Inbox row (see deliveries). A task with requireReport arms every send without the flag. A send whose paste fails drops its delivery, so a refused send leaves nothing awaiting.

    --if-runnable sends only when should-run says the task can use the tick. The daemon evaluates the verdict, claims the tab and (when the send owes a report) arms the delivery in one step, so two ticks arriving together cannot both pass; the paste itself still happens in the calling process, and the claim holds the tab busy until it lands (or 90 seconds, for a caller that died). On a skip nothing is pasted and the reply is { taskId, skipped: { run: false, reason, detail?, until?, gateIds? } } with exit 3; when the send would have armed a delivery it carries deliveryId of a skipped delivery (deliveries lists it). Repeats of the same refusal fold into one row (repeats). A skip is dropped, never held for later. dispatch and add --count do not take the flag: dispatch is an agent-to-agent reply into a live session, and a fan-out creates fresh tasks, which have no prior state to judge. send --tab new is judged on task-level state only (gates, unreported deliveries).

    --queue waits for a running turn instead of landing in it. A prompt pasted mid-turn is not held by every engine: Codex drops the work in flight and answers the new prompt, and Claude Code folds it into the running turn when the turn uses tools. With --queue, a tab that is idle takes the prompt now, exactly as without the flag. A tab that is working, waiting on a permission menu or rate-limited has it held by the daemon, and the reply is { ok, taskId, tabId, queued: true, queueId: "q-<n>", position }. Held prompts are pasted one per finished turn, in order: a hand-over happens only when the tab's state moves from running to turn_complete or idle. A turn end that comes straight from a permission menu hands nothing over (a real approval reports running before its Stop; a Stop with the menu still drawn would have Enter confirm the highlighted option). About 1.5 s after the turn ends, the tab is checked again with the same signal plain send refuses on (status.list needs-input, a permission_needed reading) and for a new running turn; either keeps the prompt held, with lastError (awaiting-permission, busy), until the next working turn ends. A report asked for with --expect-report (or a requireReport task) is armed when the prompt is pasted, not when it is held. A hand-over that does not land (the engine exited) also stays at the head with lastError; a dead engine reports no more turns, so take it back with queue-cancel (or fix its text with queue-edit). A tab whose engine reports no turn events never hands over. Held prompts live in the daemon's memory: rove daemon restart drops them. --queue does not combine with --tab new, --if-runnable or --respawn, and gate answers owed to the tab stay owed until a prompt is actually pasted.

    engine.maxLiveSessions caps new sessions. When that setting is a positive number, a call that would START an engine session (add with a prompt, send --tab new, a send to a task with no live session) first counts the live engine tabs across every task. At or past the cap it refuses with ENGINE_LIMIT, carrying taskId, live, limit, and the send that retries the same prompt. Nothing is spawned and no tab is left behind. An add refused this way has still created its task, so the retry starts it once a session ends. Delivery into an engine that is already running is never limited. Unset (the default) means no cap.

    Admission is atomic at the PTY host across independent CLI processes, including exact-tab send --tab tab-N --respawn. Pending launches and unavailable process probes retain their slots; an engine that exits into the keep-alive shell releases its slot. Refusal retries preserve --tab and --command. A host predating atomic admission refuses capped launches with ENGINE_ADMISSION_UNAVAILABLE until it is restarted; inventory contention can return ENGINE_ADMISSION_BUSY for retry.

    The prompt is pasted and submitted, unconditionally. Rove does not read the target composer or wait out a quiet keyboard first: a send that reaches a live engine tab is written to it. The only refusals left are physical — no such tab, a dead PTY, no engine process in it — and each has its own error code in the table above.

    A prompt opening with succeeded: is checked against the SENDER's own branch before any delivery: sent from a verified managed task whose branch has 0 commits, it is refused with EMPTY_SUCCESS_REPORT and nothing is delivered. The claim and the evidence are both in hand at that moment, and a report is what a coordinator acts on — land's EMPTY_BRANCH catches the same mismatch two steps later, after the coordinator has believed it. The check is deliberately narrow: it needs a verified sender identity, a managed (non-main, non-dir) task, and a definite ahead === 0 — an unresolvable base reads null and never refuses. --allow-empty states an intentional empty success (an investigation, a review) and delivers.

    Delivery result fields. Before writing a byte, delivery waits for the engine to announce bracketed paste (DECSET 2004), which is the engine saying it has taken its tty into raw mode and started reading. This is not a nicety: a pty in canonical mode DISCARDS input past the tty's 1024-byte buffer rather than blocking, so a prompt written into a still-booting engine used to arrive as a 1024-byte prefix with no error anywhere.

    • engineReady — the engine was confirmed reading when the write happened.
    • delivered — the prompt was written to the engine's pty.
    • bytes — how many bytes were written (prompt plus paste wrapper).
    • promptEcho — "confirmed" when the prompt's tail was seen echoed back, "unconfirmed" otherwise. Unconfirmed is INCONCLUSIVE, not failure: engines that collapse a large paste into a [Pasted text #1] placeholder never echo the text, so a positive proves delivery while a negative merely fails to.
    • reason — why nothing was confirmed. Present only with engineReady: false.

    Every engine receives Enter, including while it is mid-turn. Codex first receives End, which flushes its pending paste without changing the text. On Windows, Enter alone can join an unfinished paste burst as a newline and leave the message in the input box. Delivery does not read the engine's footer to choose a submit key. How an engine handles a mid-turn submission remains the engine's own behavior, not something this result reports; --queue (above) waits for the turn to end instead.

    Deliveries into one tab are serialized against each other. The paste and the submit key are one act, and two sends racing on the same tab used to interleave their halves — both messages landing in one composer, submitted together as a single turn by the first Enter while the second Enter hit an empty composer. That is what made several workers reporting into one coordinator tab look like their replies were piling up unsent. A send now waits for any delivery already in flight on that tab; one that waits too long delivers anyway rather than refusing, so a contended tab can still merge in the worst case, but a report is never dropped to avoid it.

    A FRESH spawn carries the prompt on the engine's own command line, so there is no write to observe; engineReady there reports the engine PROCESS being found inside the session, and nothing else. A hosted session stays alive after its engine exits — the wrapper execs a login shell in its place — so its liveness answers a different question, and a launch command that does not exist would otherwise report a clean success on every field. A spawn that produces no engine fails as SESSION_FAILED, carrying the already-created taskId, the session key, and the session's own last line as reason. The one non-failure that reports engineReady: false is a repo whose .rove/init.sh is still running: it precedes the engine, so delivered stays true (the prompt is still riding an argv that has not run yet) and reason says so, rather than holding add open for the length of an install.

  • queue-list [--task-id ID]: prompts send --queue is holding, oldest first: { items: [{ id, taskId, tabId, text, expectReport, at, lastError? }] }. A deleted task's held prompts go with it.

  • queue-edit --id q-N (--prompt TEXT | --prompt-file PATH): rewrite a held prompt before it is pasted, keeping its id and its place in line; { edited: true, item }, or { edited: false, reason } with reason not-found (already pasted or cancelled) or pasting (its turn came and the old text is going in now).

  • queue-cancel --id q-N: take a held prompt back before it is pasted; { cancelled: true|false }.

  • interrupt (--task-id ID [--tab TAB] | --group GROUPID | --all): stop the turn a task's engine is currently running — the headless twin of pressing the engine's own interrupt key. The session, its conversation and its worktree all survive, which is what separates this from tab-close and delete.

    It exists because the escalation had a hole in the middle. When a worker runs away, send cannot reach it (delivery needs a quiet composer, and a runaway engine's composer is exactly not that), so the only remaining levers destroyed something: tab-close throws the conversation away, delete throws the worktree away. Dispatchers took the second option because it was the only one that existed.

    Delivery is a plain PTY write of the ENGINE'S OWN interrupt bytes, from the engine registry (EngineCapabilities.interruptSequence) — the same thing a human pressing the key produces. An engine that has not declared them (every generic protocol engine) is refused with UNSUPPORTED rather than guessed at: Esc and ctrl-C mean opposite things across engines, and one of the two guesses quits the process.

    Returns { taskId, tabId, vendor, interrupted, bytes }. interrupted reports the WRITE, not the effect — an engine acknowledges an interrupt on its own screen and its own schedule, so read collect's .activity.state for what actually happened.

    interrupt --group GROUPID stops a whole fan-out round, interrupt --all every task on this machine. Only tabs whose own activity is running are written to. Idle tabs and tabs with unknown activity are left alone, even if another tab makes the task's activity running. Aliased PTYs belong to their alias owner. Returns { groupId?, count, interrupted, failures, results }: count counts members, while interrupted and failures count tabs. Each attempted tab gets a result with taskId, tabId, and status: "interrupted" (the single-task reply) or "failed" with .error/.code. A member with no known running tabs gets one "skipped" result with its task .state. One tab's refusal never aborts the rest. --tab goes only with --task-id.

  • resume-quota (--task-id ID | --all): continue a task parked on its engine's usage limit now, instead of at the quota reset — after the limit was raised or the engine signed in to another account. The daemon types the same continue prompt the reset timer would into the live session and clears the armed resume. A task is waiting when it has an armed quota resume or its engine reports rate_limited; a billing stop is not, since it needs a person. Each task gets the timer's should-run check minus the quota, so one awaiting its owner, running or at a permission prompt is declined with the should-run .reason. Returns { count, resumed, failures, results }; each result has taskId and status: resumed, not-delivered (no live engine to type into), declined or failed (.error). --task-id on a task that is not waiting fails with NOT_RATE_LIMITED; --all with none waiting returns count: 0.

  • dispatch --task-id ID (--prompt TEXT | --prompt-file PATH) [--tab TAB]: route text into a task's live session (the dispatcher's messenger; see design/dispatcher.md). Unlike send it never starts an engine — it needs a session that is already hosted. --tab tab-N delivers into exactly that tab instead of the canonical engine tab. The result's delivered is the verdict:

    • true — the daemon pasted the text into a live engine session, and tabId names which tab took it.
    • false with reason: "broadcast" — no hosted session answered, so the text went out on the session.deliver channel for a browser-hosted session to pick up. Nothing can confirm that paste; clients is a raw connection count (the calling CLI is one of them) and only its 0 proves anything — the text reached nobody.
  • note --task-id ID --text TEXT: file a one-line field note (a resolved, repo-level gotcha). Appended to the repo's durable note store, so every future session on this repo (worktree or main) starts with it in its system prompt. It starts no turn in any running session.

  • note-list --repo PATH: read a repo's accumulated field notes, newest first. Returns { notes }, each carrying the id note-delete takes. The same list is readable inside the TUI from the project header's right-click menu (Field notes, see TUI.md).

  • note-delete --repo PATH --id N: retire one field note. The note store is not an archive — its newest 15 entries are injected into every fresh session on the repo, so a note whose fact has stopped being true keeps being handed to agents as if it still were, and the only correction used to be editing the daemon's JSON by hand. Returns { deleted }; false is an answer, not an error (the retention ring may already have evicted that note).

  • set-active [--task-id ID] [--none]: set (or clear) the shared active task every attached sidebar highlights.

  • gate-raise --task-id ID [--tab T] --question TEXT [--option KEY=LABEL]… [--recommend KEY] [--context TEXT]: ask the OWNER a decision only they can make (decision gate). --option repeats, up to 6; --question ≤ 500 chars, --context ≤ 4000. --tab defaults to the tab you run in. The gate shows in the owner's Inbox as decision_needed and in gates; it stays until answered or withdrawn. Returns { gate } with the g-N id.

  • gate-answer --gate g-N [--option KEY] [--text TEXT]: answer an open gate (at least one; --option must be one of the gate's keys, else BAD_OPTION). The answer is persisted first, then pasted into the gate's tab as [ROVE GATE g-N answered] <question> / Answer: KEY — label / Note: TEXT. No live engine → { delivered: false } and the gate keeps handoff: "undelivered": nothing is queued or retried; the next send or routine firing into that tab carries the answer in front of its prompt. A prompt typed into the TUI, a TUI revive, and dispatch do not. Answering a closed gate is GATE_NOT_OPEN.

  • gate-withdraw --gate g-N: close an open gate nobody needs to answer anymore, or an answered one whose answer never reached the engine (the owner's explicit close; the answer is dropped). A delivered gate is GATE_NOT_OPEN. Deleting a task closes its open and undelivered gates.

  • gates [--repo PATH] [--task-id ID] [--status open|answered|withdrawn|all]: list gates (default open). context also lists the repo's open gates under gates.

  • pane-open [--task-id ID] [--tab TAB] [--command CMD] [--direction right|down] [--placement split|tab] [--title TEXT]: open a terminal pane in a task's workspace: split the focused tab (default, tmux-style beside/below the active pane; --tab tab-N hosts the split in that tab instead) or open a separate command tab. --command runs via your login shell (-ilc, so shell-rc PATH/exports apply, same as the engine tab) and the pane closes when it exits; omit it for an interactive shell. Broadcast over the daemon's tab.open channel, so an attached TUI showing the task performs the split (headless, nothing happens). The result carries the resolved title — the label pane-close --title must match, derived from the command's first word when --title is omitted — and clients, the attached-connection count, and reached. reached: false means no front-end subscribes to the tab.open channel, so nobody performed the split and an agent must not report "pane opened". (The calling CLI is itself one connection, so clients: 1 alone does not prove a TUI is listening; reached does.) Task defaults to $ROVE_TASK_ID (or its Rove alias), then the active task. How far splits can go is decided by the terminal's size: a split that would shrink any pane below the minimum usable size (20×6 cells) falls back to a tab.

  • pane-close [--task-id ID] --title TEXT [--tab TAB]: the inverse; close every pane (split leaf / command tab) in the task whose label matches --title, the title it was opened with; --tab tab-N scopes the match to one tab. Engine panes are never closed. Broadcast over the daemon's tab.close channel; an attached TUI performs the close (headless, nothing happens). The result's reached is the reach signal: false = no attached front-end performed the close.

  • pane-graphics [--task-id ID] --tab TAB [--image-id N]: hand opaque graphics bytes, read from stdin, to every TUI attached to this task, for it to write to its own terminal. This is a transport, not a picture format — Rove parses nothing, and the bytes are whatever protocol your terminal speaks. It exists because a pane cannot do either half for itself: its tty(1) is its own PTY slave, and the outer emulator's identity is scrubbed from its environment, so it can neither reach the real terminal nor ask it anything.

    Call it once with nothing on stdin to be given imageId (allocated per tab, so two panes never overwrite each other's picture) and cellWidth/cellHeight in pixels — the numbers that turn a pixel size into a count of cells. Build your payload around those, then pipe each frame in with --image-id, which replaces that picture in place instead of leaking a fresh id per frame. wrote reports whether anything was broadcast, and clients is the usual reach signal.

    ok: false with unsupported is a real answer, not an error: no-cell-size means no attached terminal could measure a cell (it declined CSI 16 t), and mixed-cell-size means two attached terminals reported different ones — a task can be open in two GUIs at different font sizes, and there is no single honest answer then. Fall back to whatever you draw without graphics.

  • tab-close --task-id ID --tab TAB: close one exact Terminal Tab using the id returned by get-task in .tabs[].id. Engine, interactive-shell, command, and content tabs are all valid. With an attached TUI, the command runs the same close path as ctrl+w, so the tab strip updates immediately; headless, it closes the tab in the daemon's tab store and ends the tab's hosted PTY plus any split-leaf PTYs directly. Closing the last tab leaves the task open with no session, matching ctrl+w. A tab that still exists in the snapshot may be closed after its process dies; an absent or already-closed id returns TAB_NOT_FOUND with a get-task recovery command.

  • tab-new --task-id ID --kind shell|engine [--command CMD | --latest] [--type TEXT]: open a new Terminal Tab with no first message — the TUI's sidebar "New shell" and its picker's Enter on an empty message. shell opens a shared child task of --task-id (same directory, titled shell, so the rail and the TUI tree list it) whose own tab-1 runs the login shell ($SHELL) in the task's worktree and types the ROVE_TASK_ID / ROVE_TAB_ID export into it. A fresh Ungrouped (scratch) task with no tabs is the exception: its shell is its own tab-1. When the shell exits the row stays with no tabs (reopenAs: command), and ⏎ in the TUI or Start in the web starts a fresh shell; nothing deletes the task. --type TEXT (shell only) types TEXT into the new shell once it is up and presses Enter, after the ROVE_* export; the tab stays a normal shell, so Ctrl+C stops what it started. engine starts a new conversation. A task with no conversation yet (no tabs) starts it in its own tab-1, on --command's engine when given (the task is re-pointed to it first). A task that already holds one gets a shared child task of --task-id (same directory, as add --no-worktree) whose engine, the task's own or --command's as send --tab new --command pins it, starts idle in the child's own tab-1, ready for a first message. A task holds one engine conversation, so this never adds a second engine tab to it. --latest instead starts the engine this project used last, else the default engine (the TUI's ctrl+t; the web's new-conversation click). The worktree is created if needed, and an engine start respects engine.maxLiveSessions (ENGINE_LIMIT; the refused child is removed). The daemon's tab store mints the tab id and adds the row (one tabs.apply, docs/design/daemon-owned-tabs.md), so it never collides with a tab another client opens at the same moment, then the session spawns; every attached client sees the row through tabs.changed. A shell returns {ok, taskId, childTaskId, tabId, session, kind} with tabId tab-1 of the child (an Ungrouped task's own shell returns {ok, taskId, tabId, session, kind, clients}; clients = attached connections told, 0 = none); an engine on a task with no conversation returns {ok, taskId, tabId, session, kind, clients} with tabId tab-1 of the task; an engine on a task that holds one returns {ok, taskId, childTaskId, tabId, session, kind} with tabId tab-1 of the child and no clients. The tab then lists in get-task .tabs[]. A session already running under the allocated id fails as TAB_COLLISION. --command or --latest with --kind shell is BAD_FLAG. tab-new also opens three kinds that run something Rove resolves, never a command line from the caller: --kind editor --file PATH [--line N] opens PATH (relative to the worktree; .. and a symlink out are BAD_FLAG, a missing file FILE_NOT_FOUND) in the editor from Settings → General → Editor, else $VISUAL/$EDITOR, else the first installed of nvim/vim/emacs/nano (NO_EDITOR when none), as a command tab in the task's one editor slot: the previous editor tab is closed (through the TUI's close path when one is attached) once the new one runs. --line N opens at line N: +N for vi/vim/nvim/nano/emacs/emacsclient/micro/kak/mg/joe, PATH:N for helix, or wherever {line} sits in editor.customCommand; the result then carries atLine — false when the editor has none of those forms and the file opened at its top. --line with any other kind is BAD_FLAG. --kind pane --pane PLUGIN.PANE [--tab TAB] opens a pane of an enabled plugin (PANE_NOT_FOUND otherwise), split into --tab (default the active tab) or as its own command tab, as the pane's manifest placement says and as the TUI's picker does; the result then also carries leaf when it split. --kind viewport --view-task ID adds an engine tab that shows another task's first engine session (.tabs[].viewOf names that task; its liveness is that session's) and starts nothing; the task must be in the same project and have a worktree.

  • tab-split --task-id ID --tab TAB --action open|close|rename|reset [--leaf leaf-N] [--direction right|down] [--title TEXT] [--cols N --rows N]: edit one Terminal Tab's split panes with no TUI — the TUI's ctrl+\ / ctrl+= / ctrl+w / F2 / F5. open splits --leaf (default: the tree's active pane) toward --direction (default right) into a new login shell in the task's worktree; --cols/--rows are that pane's size, for the minimum-pane gate (too small → changed: false, nothing spawned). close ends --leaf (the last pane is LAST_LEAF: close the tab instead), rename sets its label (an empty --title clears it), reset kills a shell pane and spawns a fresh login shell (an engine pane is RESET_ENGINE). leaf-1 is the tab's own process; every other pane is its own hosted session <taskId>::<tabId>::<leaf>, swept by tab-close. The layout is the tab's split tree in the daemon's tab store (one patch), which every attached client then shows. No command line is taken: panes are always the login shell. Returns {ok, taskId, tabId, leaf, changed, split, clients}; split is {activeLeafId, root} (null once back to one pane) and get-task .tabs[].split carries the same.

  • tab-revive --task-id ID --tab TAB [--restart-exited]: reopen one Terminal Tab's own session when no PTY runs in it: a dead or freeze-restored tab, or one whose key the PTY host lost (a host restart that dropped its freeze records). A running tab is left alone (revived: false, alive: true; an engine that exited into its keep-alive shell still counts as running) unless --restart-exited is set and a process walk finds no engine in the session (get-task .tabs[].engineAlive false): that session is then closed and relaunched like a dead one. A shell tab, an unwalked tab and a tab with a running engine are never replaced. An engine tab resumes its persisted conversation (resumed: true, the sessionId it reopened) or, with none recorded, starts a fresh one with no first message and records the id the engine pinned; it respects engine.maxLiveSessions. The task's original prompt is never replayed. A shell or command tab runs its recorded command in the task's worktree. A tab id the task holds no row for is TAB_NOT_FOUND; a split leaf (tab-1::leaf-2) is BAD_FLAG (tab-split --action reset reopens it); a preview or viewport tab is TAB_NOT_REVIVABLE. A task on another registered machine runs there, like every verb. Returns {ok, taskId, tabId, revived, alive?, resumed, sessionId?}.

  • panel-show --view browser|files|diff [--url URL] [--path FILE] [--task-id ID]: bring up the web side panel (Files / Diff / Browser) on a task. --url (browser view; http/https only) loads a page; --path (files and diff views) is a file relative to the task's worktree, with no ... Broadcast over the daemon's panel.show channel and pushed to every browser: each web page remembers the view for that task, and one currently on the task with a side panel opens it, without stealing focus or switching task. A TUI has no panel. Task defaults to $ROVE_TASK_ID, then the active task, as pane-open does. Returns { shown, remembered, viewers, url? }: shown is the web pages on the task with a panel (the ones that open it now), remembered every web page attached and viewing a task (they keep the view for later), viewers the task's viewers rows. For --url, url is { reachable, frameable, reason? }. reachable: false is a localhost URL while a viewing page is remote (behind a tunnel, so its localhost is not yours), or a host the probe could not reach. frameable: false means the page cannot sit in the panel: it sends X-Frame-Options: DENY/SAMEORIGIN or a CSP frame-ancestors that does not allow *, or plain http outside localhost, or the probe refused it. Tell the human the link instead. The probe is one HEAD over HTTPS to a public address (a private, loopback, link-local or unique-local address is refused, after resolving the name), follows no redirect, and gives up after 3 seconds. localhost URLs are not probed.

  • notify --title TEXT [--body TEXT] [--kind KIND] [--task-id ID] [--source TAG]: show a toast in every attached Rove UI. --body adds a second, muted line under the title — context, not a second message. done / needs_input / error get severity styling; any other kind renders neutrally. The result's clients is the reach signal: 0 = no attached UI showed the toast (headless).

  • prompt --title TEXT [--placeholder T] [--initial T] [--timeout MS]: ask the human for a line of text through the attached TUI's input dialog; blocks until answered/cancelled/timeout (default 120000 ms, max 600000) and returns { value } or { cancelled, reason }. reason is cancelled, timeout, or no-gui: with no front-end attached to show the dialog, it returns at once instead of waiting out the timeout.

  • engine-report --kind KIND [--task-id ID] [--engine ID] [--tab TAB] [--detail JSON]: report a normalized engine-activity verb for a task, the public face of the engine.reportEvent RPC the built-in hook adapters use. Lets a plugin-contributed engine (or any wrapper) drive the sidebar badge, attention inbox, and plugin event stream. Task/tab default to $ROVE_TASK_ID / $ROVE_TAB_ID; without either, the caller's cwd is mapped to a task by worktree path. Kinds: session-start, turn-start, turn-complete, turn-failed, turn-interrupted, awaiting-input, session-end (activity state), plus tool-pre/post/failed, pre/post-compact, subagent-start/stop (plugin-only).

  • report --delivery ID --outcome O --summary TEXT [--evidence X]… [--next TEXT] (or --json PATH|-): close the report that a delivered prompt asked for. ID is the d-<n> in the prompt's [ROVE] or [ROVE ROUTINE] line. O is done, partial, blocked or failed. --evidence repeats: a commit, PR url, test command with its result, or a file path (up to 50 items, 2,000 characters each). --next says what the next tick should do (4,000 characters). --summary is capped at 32,000 characters (RESPONSE_TOO_LARGE, never truncated). --json reads { delivery, outcome, summary, evidence?, next? } from a file or stdin and cannot be mixed with the individual flags; use it for text with quotes or backticks. An unknown id is DELIVERY_NOT_FOUND. Run it as the last step of the turn.

    --outcome done on a send delivery gets the same check as a succeeded: send: from a verified managed task whose branch has 0 commits it is refused with EMPTY_SUCCESS_REPORT and nothing is recorded. Pass --allow-empty when the task genuinely produced no commits. Routine and gate-answer deliveries, and the outcomes partial, blocked and failed, are never refused.

    Reporting again replaces the earlier report. A report on a missing delivery flips it to reported and clears its report_missing Inbox row. The first line of --summary (up to 280 characters) also refreshes the task card's report summary, so no second update --report-summary is needed. For a prompt delivered by send (not a routine or a gate answer) to a task with a recorded dispatcher, the daemon also pastes one line into the dispatcher's tab: [ROVE REPORT] "<title>" (task <id>, branch <b>) <outcome>: <first line of summary>. It carries no footer and asks for no reply, goes to the dispatcher rather than whoever sent the prompt, and is skipped when that tab is at a menu or has no live engine. The reporting agent sends no separate succeeded: message. Daemon RPCs behind this: delivery.arm, delivery.settle, delivery.report, delivery.list, task.requireReport.

  • attention-dismiss --delivery ID: dismiss the report_missing Inbox row of a delivery. ID is the d-<n> from the row. This is the explicit way to accept an unreported tick: opening or visiting the row does not clear it, because the verdict keeps blocking the next tick until a late report or this dismiss. An unknown id is DELIVERY_NOT_FOUND. Daemon RPC: attention.dismiss with deliveryId.

edit

  • update [--task-id ID] [--title T] [--branch B] [--parent ID|none] [--project PATH] [--command CMD] [--model M] [--effort LEVEL] [--pinned true|false] [--kept true|false] [--require-report on|off] [--auto-fix-ci on|off] [--status S] [--report-branch B] [--report-pr N] [--report-summary TEXT]: change any combination of a task's fields in one call. The flags are checked before anything is written, then applied in a fixed order: parent → project → branch → command → model/effort → title → pinned → status. The call is not atomic: if a step fails midway, the error reports the fields already applied. The result is { ok, taskId, updated, parent?, command?, protocol?, generic?, engine? }, where updated lists the fields written, in order. Without --task-id it targets the caller's own task (the verified $ROVE_TASK_ID session), so an agent can retitle the task it runs in; outside a verified session that is MISSING_TARGET.

    • --title T: the task's title. With --tab TAB it names one Terminal Tab instead (the API twin of the TUI's f2) and --title is the only other flag allowed. TAB_NOT_FOUND when the task's snapshot names no such tab — get-task lists the addressable ids in .tabs[].id.
    • --branch B: rename the task's branch (git branch -m if materialized, else recorded).
    • --parent ID|none: move the task under another task (none: to top level) without touching its engine, tabs, worktree or branch. Two things move together: the tree link, and the reply address a bare send from the task follows. After update --task-id A --parent B, A's bare send reaches B — the caller's own tab when update runs from inside B, else B's live engine tab, else tab-1. The stored parent is B's top-level ancestor (children never nest, as with add from a child), and A's own children follow it there. Tree actions on that stored parent reach A as well: archiving it, or delete --cascade on it, takes A along. .parent in the result is { parentTaskId, dispatcher, moved }. none also drops a reply address that pointed into the old tree. A parent must live on the same machine as the task: a --task-id on another machine runs there, so a parent that is not on that machine is PARENT_NOT_FOUND. Other refusals leave every row as it was: PARENT_IS_SELF, PARENT_CYCLE (the parent descends from the task), TASK_NOT_MOVABLE (a project's main task, or a routine session), REMOTE_PARENT_UNSUPPORTED (either task lives on a remote project).
    • --project PATH: move a top-level task (and its children) under the project at PATH: an Ungrouped session after it cloned or git init-ed its repo, or a task created in the wrong project. PATH may be any directory inside the repo; it names the repo's main checkout, which becomes a project (its main row is created) if it was not one. Only the grouping changes: the PTYs, cwd, worktree and branch stay where they are, so a running engine keeps its conversation. The result's .project is { repo, moved, note }, moved listing every row regrouped. A worktree task keeps a gitRepo link to the repo that cut its checkout, so delete, land and forks still run there; moving it back clears the link. Refusals, before anything is written: NOT_A_GIT_REPO, SAME_PROJECT, TASK_NOT_MOVABLE (a project's main task, a routine session, or a multi-repo task), CHILD_NOT_MOVABLE (move the parent instead), REMOTE_MOVE_UNSUPPORTED. The TUI and web row menus offer the same move as "Move to project…".
    • --group GROUPID (with --parent only): move every task of one fan-out round (the groupId that add --count returns). A member's refusal is recorded in .results[] (status: "failed", code) instead of stopping the rest; the result is { ok, groupId, count, failures, results }. A parent that is itself in the round is refused up front as PARENT_IN_GROUP.
    • --command CMD: the engine launch command (takes effect on next session rebuild). The protocol Rove speaks to it is derived from the command; the result reports which one, and generic: true when the command names no engine Rove knows.
    • --effort LEVEL: reasoning effort (next session rebuild). Levels are declared by the task's engine — codex accepts none, low, medium, high, xhigh, max; claude declares none. A level the engine does not declare is rejected (BAD_EFFORT, naming the levels it does accept) rather than passed through, because the launch path drops an unknown level silently.
    • --model M: pin the model (next session rebuild). Passed to the engine verbatim in its own spelling; engine-list's models are suggestions. Rejected (BAD_MODEL) when the task's engine declares no model flag. A --command that changes the task's engine clears its model and effort pins; pass --model / --effort in the same call to re-pin them.
    • --pinned true|false: pin/unpin the task to the top of the sidebar.
    • --kept true|false: mark the task as the attempt kept from its parallel round (add --count / --agents, same groupId), or clear the mark. A flag only: it archives nothing and leaves the round's other attempts alone. list and get-task report it as kept.
    • --require-report on|off: on makes every later send to the task arm a delivery, as add --require-report does; off restores plain sends.
    • --auto-fix-ci on|off: on lets the daemon fix failing CI unattended. When the task's open PR goes red on a head commit not yet tried and its engine is at rest (idle / turn_complete, never mid-turn), the daemon types the fix-CI prompt, with the failing job logs, into the engine session: at most once per head commit and 3 times in all, then it stops and raises an Inbox row. Green checks start the count over. A busy engine only delays it; a prompt nothing took (no live engine, gh not answering) spends no attempt. .autoFixCi on list / get-task is { attempts, lastSha?, stopped? }, absent when off. off stops it and forgets the count. Daemon RPC: task.autoFixCi.
    • --status S: lifecycle status: backlog, in_progress, in_review, done, canceled, error.
    • --report-branch / --report-pr / --report-summary (require --status) record what the WORKER says it delivered, as .report on the task ({ branch?, pr?, summary?, at }), readable from get-task and collect.

    .report is a CLAIM; .prStatus is an OBSERVATION. The daemon polls the forge for prStatus.number / prStatus.checkState; report.pr is whatever the worker typed, and a worker can report a PR that does not exist. A dispatcher deciding whether to land needs to know which of the two it is holding, which is why they are separate fields and not one merged view.

    Report fields MERGE onto any previous report and restamp at, so a follow-up naming only --report-pr keeps the branch reported earlier. An update --status with no --report-* flag writes no report at all — an empty one would restamp at and claim the worker reported again.

    The old per-field verbs are gone (no aliases): calling one returns UNKNOWN_VERB with the update form in nextCommandArgs.

  • pr-attach --url URL|N [--task-id ID]: show a pull request on a task's row. The daemon's PR poller only follows a task's own branch, so a PR its session opened from another directory (a main coordinator's hand-made worktree) is invisible until attached. Run it once after gh pr create, from any directory. --url takes a GitHub PR URL (a trailing /files or #fragment is dropped) or a bare number, which gh pr view resolves in the current directory's repo. Without --task-id it targets your own task (MISSING_TARGET when the caller is not a verified session). Idempotent; a task keeps its 20 newest PRs. Returns { taskId, url }. publish attaches the PR it opens or finds, so it needs no second call.

  • todo [--task-id ID] [--add TEXT] [--check ID] [--uncheck ID] [--edit ID --text T] [--remove ID] [--notes TEXT | --clear-notes]: read or change a task's notes and checklist, the owner's own review list. With no flag it lists them. Flags combine in one call and apply together in the order add, check, uncheck, edit, remove; a refused op changes nothing. Todo ids are t<N>; text is one line up to 500 characters, 100 todos per task, notes up to 20000 characters. Refusals: TODO_NOT_FOUND, TODO_EMPTY, TODO_TOO_LONG, TODO_LIMIT, NOTES_TOO_LONG. Defaults to the calling session's own task. Returns { ok, taskId, notes, todos: [{ id, text, done }], done, total }. Daemon RPC: task.notes. The web shows done/total on the task row and warns (never blocks) in the land confirm while todos are unchecked.

issues

The daemon-owned issue store (backlog; see WORK-TRACKING.md). Statuses: open, doing, hold, done. The store belongs to the Kanban first-party mod: until it is installed (rove plugin install kanban), these verbs are left out of schema and --help and fail with MOD_NOT_INSTALLED (nextCommandArgs installs it), and the daemon refuses the issue.* RPCs the same way.

  • issue-list --repo PATH [--status S1,S2] [--search Q]: list a repo's issues. --status keeps any of the listed statuses; --search keeps issues whose title or body contains Q, ignoring case. Filters AND together. nextId and skipped still describe the whole board.
  • issue-create --repo PATH --title T [--body TEXT]: create an issue.
  • issue-update --repo PATH --id N [--title T] [--body TEXT] [--task ID] [--status S]: edit title/body, link a task (kanban: In progress; --task none unlinks) and/or set the status. Title, body and link land in one store write, so a rejected --task leaves them unchanged — the error means nothing was applied. --status follows as its own setStatus change (the op plugin issue.changed events report).
  • issue-delete --repo PATH --id N: delete an issue. Removes ONLY the tracker record — a linked task, its branch and its worktree are left untouched. The same store op the kanban page's d runs, which the CLI could not reach: an agent asked to clear a batch of stale stories could previously only mark them done and leave them. Use issue-update --status done when the story was finished rather than abandoned.

workitems

A read-only view of a repo's GitHub issues (through the gh CLI), plus one action: start a task on one. Owned by the Issues first-party mod: absent and refused with MOD_NOT_INSTALLED until rove plugin install issues. Deliberately not an import; the issue stays GitHub's, and nothing is copied into Rove's own issue store. Mechanics: design/work-items.md.

  • workitem-list --repo PATH [--state open|closed|all] [--limit N] [--search Q] [--assignee USER] [--author USER] [--label L]: list issues. --assignee @me for the ones assigned to you, --author @me for the ones you opened.
  • workitem-start --repo PATH --number N [--vendor V] [--base-branch B]: create a task for issue N and start its engine with the issue title, body, and URL as the first message. The task keeps a linkedWorkItem pointing back, and its branch derives from the issue title following the repo's branch-naming convention.

Requires gh installed and authenticated; failures name which of those is missing (gh-missing / auth / no-remote) rather than a generic error.

routine

Scheduled agent tasks (Routines): a cron rule + a prompt + a repo (or no repo). By default every firing creates a fresh task (worktree + branch + engine session) with the prompt as its first message; --persistent-session instead re-delivers into ONE standing task. --target-task ID --target-tab tab-N instead binds an existing conversation without creating or reviving a task or tab. An enabled routine keeps the daemon alive so schedules fire with no TUI attached. Walkthrough: Routines. Mechanics: design/automations.md.

  • routine-list: every routine with its next run time.
  • routine-create (--repo PATH | --no-repo) --name N (--prompt TEXT | --prompt-file PATH | --script CMD [--script-timeout SEC]) --schedule CRON [--vendor V] [--model M] [--effort LEVEL] [--base-branch B] [--precheck CMD] [--precheck-timeout SEC] [--grace MIN] [--persistent-session [--new-conversation]] [--disabled] [--target-task ID --target-tab TAB] [--notify]: schedule a prompt (or, with --script, a shell command with no engine: Script routines; --notify hands each run's response to the bound chat agent). Script routines cannot set --vendor, --model, --effort, --base-branch, --persistent-session, --target-task, or --target-tab. Pass exactly one of --repo and --no-repo (neither is MISSING_FLAG, both is CONFLICTING_FLAGS). --no-repo makes a routine with no project: every run works in <home>/.rove/routines/<id>/ as a directory session, and it takes no --base-branch. --schedule is five-field cron in the daemon host's local time ("0 9 * * MON-FRI"). --model / --effort pin every run's engine, validated at save time with the same BAD_MODEL / BAD_EFFORT as add against --vendor, else the repo's default engine, else the built-in default; a pinned routine stores that vendor, so runs launch the engine the pins were checked against. Without pins or --vendor, runs launch the built-in default. The daemon refuses model/effort without a vendor.
  • routine-update --id ID [...]: change any field (including --script, --script-timeout, --notify). To turn a script routine back into a prompt routine, pass --script '' and --prompt TEXT in the same update. Changing --script-timeout requires repeating --script CMD, even for an existing script routine. --enabled true|false pauses / resumes. A new --schedule re-anchors the next run; --precheck '' clears the precheck, --model '' / --effort '' clear those pins, and --vendor '' under surviving pins re-resolves, re-validates and stores the engine. Omitted target flags preserve the binding; --target-task '' --target-tab '' sends target: null to clear it. Changing or clearing vendor/model/effort while a routine retains a standing task lets go of that task (returned as releasedSessionTaskId, never deleted); the next run builds a new one on the new pins. Unchanged pins keep it.
  • routine-run-now --id ID: run immediately, skipping the precheck. Does not shift the schedule.
  • routine-respond --run RUN_ID (--text T | --prompt-file PATH|-): store the agent's response on one run. It is the routine spelling of report --delivery <deliveryId> --outcome done --summary <text> and writes the same delivery report, so deliveries shows it too. Each delivered routine prompt starts with a [ROVE ROUTINE] "<name>" run #<n> — when done, report with: rove api report --delivery d-<n> --outcome done|partial|blocked|failed --summary … [--evidence …]… [--next …] (only if something happened or you are blocked) line, then the prompt. A --persistent-session routine sends the full prompt only when its engine starts a new conversation, or when the prompt changed since the last run delivered to that conversation; other runs, a revived one that reopened the conversation included, get one line saying the instructions are unchanged. One response per run, a second call replaces it; cap 32,000 characters (RESPONSE_TOO_LARGE, never truncated); an unknown run id is RUN_NOT_FOUND. No response is ever inferred from a transcript. Prefer report when the run ended partial, blocked or failed, or when you have evidence to attach.
  • routine-runs --id ID: run history, newest first. Each delivered run carries deliveryId and deliveryStatus (awaiting, reported, missing, or quiet: the turn completed with nothing to report), and response: { text, at } once the agent reported; both are read from the linked delivery. Runs from before deliveries existed keep the response they stored. revived describes a respawned standing session; skipped_cancelled means disabled, changed or stopped before delivery; skipped_blocked means should-run refused the tick (blocked is the verdict, see should-run; the cause is already in the Inbox and routine-run-now bypasses it), except engine-dead and engine-error, which have no Inbox row of their own and are recorded dispatch_failed with the same blocked. Bound deliveries include taskId/tabId. An unknown id is an error (automation not found), not an empty history.
  • routine-delete --id ID: delete it and its history (tasks it already created are untouched). Idempotent: deleting an id that is already gone succeeds with { "deleted": false }.

--persistent-session keeps ONE task per routine and delivers each firing into it, so a daily check can build on yesterday. Every routine task, standing or one fresh run's, stays out of the sidebar; it opens from the Routines page and still raises Inbox entries. Leave it off for a routine that edits code: a week of runs on one branch is a branch nobody can land. One extra run status comes with it — revived (the engine is stopped after every run, so it was started again in the same worktree; the conversation is reopened where the engine has a resume command). --new-conversation (with --persistent-session only) keeps the standing task and worktree but starts a new conversation on every run instead of reopening the last one; routine-update --new-conversation false turns it off.

--webhook also fires the routine on a POST to the web server's /hooks/routines/<id> signed with .webhook.secret (GitHub's X-Hub-Signature-256). The precheck still runs, with ROVE_WEBHOOK_EVENT and ROVE_WEBHOOK_ACTION set. The run records trigger: "webhook" and an event line, which also opens the delivered prompt. --webhook false forgets the secret. See Routines.

Existing target: the daemon payload is target: {kind: "existing-tab", taskId, tabId}. The repo must match the task repo; vendor, model, effort, baseRef and persistentSession are incompatible with this mode. Updates validate the merged record; clear old launch settings with --vendor '' --model '' --effort '' --base-branch '' --persistent-session false when binding. Missing/deleting tasks, missing tabs and exited engines fail without fallback. Disabling stops future scheduling, including a run still in precheck. Claims survive restarts without replay, but a crash after claim and before delivery can lose an occurrence. See existing conversation delivery.

--precheck runs a shell command in the repo before the engine starts; a non-zero exit skips the run without creating a task. Use it so a schedule does not burn a turn when nothing changed (git log --since=24.hours --oneline | grep -q .). Run statuses: dispatched, skipped_precheck (healthy: nothing to do), skipped_missed, skipped_unavailable, and dispatch_failed (needs a human).

lifecycle

  • publish --task-id ID [--remote NAME]: push the task's branch (to origin unless --remote) and open its pull request with gh, or return the one already open; land for a task on another machine. Returns { branch, pushed, pr: { url } | null, prError? }: prError says why there is no PR (gh missing or signed out, a non-GitHub remote, no commits ahead) and the push still happened. Refuses a task with no worktree or branch. The PR it opens or finds is attached to the task.

  • land --task-id ID [--dry-run] [--repo NAME] [--strategy merge|squash] [--delete-branch] [--remove-worktree=false]: merge a task's branch back into its base repo's current branch (--no-ff merge, or one squash commit). Refuses a dirty base checkout, a branch that no longer resolves in the base repo (MISSING_REF — renamed or deleted outside Rove), and a branch with no commits ahead of base (EMPTY_BRANCH; EMPTY_BRANCH_DIRTY_WORKTREE when uncommitted work is still sitting in the worktree, with a send-back recovery command); on conflict it aborts and returns the conflicted files. Returns { landedOn, commit }. A successful land removes the task's worktree by default — the directory is spent once the branch is in. Pass --remove-worktree=false to keep it. The branch always stays either way (pair with --delete-branch to drop it too); git is the durable record, the working directory is not. Removal never forces: a dirty worktree, the base checkout, and the worktree the caller is running from are all refused, and the outcome lands in the result's worktree field ({ removed, reason? }) instead of failing the land.

    --dry-run answers "may this land, and into what" without writing. It returns { branch, landedOn, ahead?, baseDirty?, refusal?, dirtyFiles?, baseDir }: landedOn is the base checkout's CURRENT branch — the merge destination, which is the thing "check the base checkout is on the branch you mean" asks you to check — and ahead is how many commits would land. When the land would be refused, refusal is one of DETACHED_HEAD, UNREADABLE_BASE, UNBORN_BASE, SAME_BRANCH, MAIN_CHECKOUT_DIRTY, MISSING_REF, EMPTY_BRANCH, EMPTY_BRANCH_DIRTY_WORKTREE, and message carries the same words the land itself would have failed with. A coordinator picking which sibling of a round to land should read this first — ahead: 0 is the empty-merge that otherwise only surfaces at land time.

    --delete-branch needs the worktree gone. git refuses to delete a branch a live worktree has checked out, so pairing --delete-branch with --remove-worktree=false — or with a removal that got refused (dirty tree, base checkout, the caller's own worktree) — keeps the branch. The result says so in branchKept ({ reason }) and writes no branchAnchor.

    A multi-repo task lands every repo, in picked order (primary first). Every repo is probed first; if any refuses (dirty base, missing ref, …) nothing is merged and the error names it. Then each repo is merged into its own base, and the run stops at the first conflict (that repo's merge is aborted, earlier repos stay landed). The error carries .repos[] — one row per repo, { name, status, landedOn, ahead, commit, error?, code? } with status one of landed, skipped (no commits ahead: already landed, or untouched), conflict, refused, failed, pending — and .failedRepo. Re-running land resumes: repos already merged read as skipped, the rest carry on. A fully landed run returns the first landed repo's { landedOn, commit } plus the same .repos[]. Worktrees (and, with --delete-branch, branches) are removed only once every selected repo has landed — the primary is the engine's cwd, and a conflict in a later repo is resolved from it. So --repo <primary> while other repos still have worktrees merges but keeps the primary's worktree and branch (reported as worktree.removed: false with the reason); an extra named with --repo is cleaned right away. A repo whose branch Rove already landed and deleted (--repo X --delete-branch) reads as skipped, not MISSING_REF, on the next full land. --repo NAME (a name from .task.extraRepos[].name; the primary's is its directory name) lands, or with --dry-run probes, just that repo; an unknown name is UNKNOWN_REPO. --dry-run returns the head repo's fields at the top level (the first refusal, else the first repo with commits to land) and .repos[] with each repo's own probe and a status of ready, skipped or refused.

  • sync --task-id ID [--repo NAME]: merge the base branch INTO the task's worktree (the inverse of land; the ↓N drift chip's action). A dirty worktree is refused (SYNC_WORKTREE_DIRTY, commit first), and a conflict is left in place in the worktree for you to resolve (SYNC_CONFLICT lists the files). Returns { baseRef, alreadyCurrent }. A multi-repo task syncs every repo against its own base and adds .repos[] ({ name, ok, baseRef?, alreadyCurrent?, error?, code? }); one repo's conflict does not stop the others, and the first failure is raised as the error once all have run, with .repos[] and .failedRepo.

  • delete (--task-id ID | --group GROUPID) [--force] [--delete-branch] [--delete-remote] [--cascade] [--wait]: remove a task and its worktree. The git branch stays unless --delete-branch is passed; git is the durable record, the task row is not. Needs --force on a dirty worktree; --force never implies --delete-branch.

    --delete-branch deletes the LOCAL branch by git's own rules. git branch -d refuses a branch whose commits the base cannot reach — the ordinary case for work that never landed — but also accepts anything the branch's upstream already contains, so a pushed branch deletes even unmerged. --force upgrades the delete to git branch -D and takes the unmerged case too. The removal succeeds either way, by design.

    --delete-remote is a separate opt-in that --delete-branch never implies. A local branch is recoverable from any clone that still has it; a remote one is recoverable by nobody, and deleting it closes an open PR. It pushes git push <remote> --delete <branch> to the branch's own configured remote (branch.<name>.remote), falling back to origin.

    The branch outcome is in the reply. With either branch flag the result carries branch:

    { "branch": "fix/x", "deleted": true,
      "remote": { "name": "origin", "deleted": true } }

    deleted: false comes with keptReason — git's own sentence about why it kept the branch (unmerged work under -d, or a sibling worktree still holding it). The point is that you can now read which happened instead of inferring it from status: "removed", which is the worktree's outcome and never the branch's. branch.remote.deleted: false carries error the same way.

    Both flags therefore imply --wait: git refuses to delete a branch a live worktree still holds, so a verdict read before the removal resolves would be describing the worktree. The daemon still logs branch kept task <id> branch=<name> — git refused the delete: <reason> to ~/.rove/daemon.log, next to the removed … line.

    The delete gate refuses what it cannot read. Without --force, deletion probes for gitignored work (git status --ignored); a probe that fails is a refusal, not a pass. See docs/WORKTREES.md.

    The removal runs in the background — tearing down a worktree can take tens of seconds — so the default reply reports only that the request was taken: { taskId, queued, status } with status either queued or not_found. queued: false means no deletion was scheduled at all (no task by that id), and it is deliberately distinguishable from acceptance: the two used to return the same empty object, so a caller deleting a list could not tell which entries were even accepted.

    --wait follows the deletion to its outcome and reports it:

    • removed — the worktree and the task row are gone.
    • failed — removal failed and error carries git's own message (a locked or non-empty directory, a permissions problem). The task KEEPS its row, with deletion.phase: "error", so it stays visible and re-deletable. Before this existed the failure reached daemon.log and nothing else, which made a failed delete indistinguishable from a successful one.
    • pending — still running after 60s. Not a failure: the daemon still owns it, so look again with list rather than retrying the delete.

    A deletion's state is also readable at any time from list: the task's deletion field carries phase (queued / running / error) and, on error, the message.

    --group GROUPID closes a whole fan-out round in one call, selecting by the same groupId collect --group takes (the one add --count returned). Creating and reading were already batched; only deleting was one call per loser, and the documented workflow ends by removing the N-1 that lost. Returns { groupId, count, failures, results } with one entry per sibling — each the same object a single delete returns, or { taskId, status: "failed", error, code }. A refusal on one sibling (a dirty worktree is the common one) is RECORDED rather than thrown, because aborting there would leave the caller unable to tell which of N were already removed. Mutually exclusive with --task-id.

    A task with children (nested by add) is not orphaned. By default its children are promoted to the deleted task's own parent, or to top level, and keep running; --cascade deletes the whole subtree instead, and the reply lists every removed id as deleted. A directory goes only once no remaining task points at it, so deleting a shared child never touches its parent's files, and every removed worktree still passes the dirty gate.

  • archive --task-id ID [--force] / unarchive --task-id ID: hide a task from the sidebar, the web rail and the default list, or bring it back. Nothing else changes: the worktree, branch, transcript and any running session stay as they are, and every verb that takes --task-id (send, read-output, get-task, context, collect, digest, delete) keeps working on the task by id. list --archived lists only archived tasks. The Inbox leaves out an archived task's rows, an open decision gate included; they are kept and show again after unarchive. Both verbs act on the task and every descendant (child tasks, recursively) together, and return { taskId, archived, ids }, where ids is every task they changed. A child unarchived on its own while its parent stays archived shows as a top-level row until the parent comes back. archive is refused with TASK_BUSY while an engine in the subtree is running or permission_needed; --force archives anyway and still stops nothing. A project's main task cannot be archived (MAIN_TASK), because it is the project row itself. The sidebar and the rail do not list archived tasks. To bring one back from the interface, open the command palette (ctrl+a /), type its title and pick it. A project whose other tasks are all archived is hidden like any idle project.

worktree

  • ensure-worktree --task-id ID: materialize a task's git worktree on disk now (without starting an engine). Returns { worktreePath }.

  • remove-worktree --task-id ID [--force]: its INVERSE — remove the worktree directory and keep the task row and its branch, so ensure-worktree can materialize it again. This is what a script reclaiming idle checkouts wants; delete takes the task record with it. Returns { worktreePath, branch, removed, residue? }.

    Runs the same path as the Worktrees page's delete: the engine session is torn down before the directory is unlinked, a dirty tree is refused without --force, and every forced removal first takes a salvage snapshot into refs/rove/salvage/<branch>-<stamp>. Two refusals are this verb's own, because it is scriptable and its caller is often an agent inside the very worktree it names: BASE_CHECKOUT (the project's own checkout) and CALLER_WORKTREE (the directory the command is running from). NO_WORKTREE when the task never materialized one. residue means git deregistered the worktree but could not delete the directory — the removal is as complete as git can make it, and this is the only time that leftover path is named.

  • checkpoint-list --task-id ID: the task's checkpoints, oldest first — file-only undo points of its worktree. The daemon takes one when an engine turn completes (skipped if the files did not change since the last one); the newest 100 per workspace are kept. Returns { taskId, workspaceTaskId, checkpoints: [{ n, ref, commit, tree, at, tabId? }] }; each ref is refs/rove/checkpoints/<workspaceTaskId>/<n> in the owning repo, so git diff <ref> and git restore --source=<ref> work without Rove. Managed tasks only: a project's main checkout and a directory task are your own checkout, so they get NOT_CHECKPOINTABLE.

    Checkpoints belong to the workspace owner. A child that shares its parent's directory (add --no-worktree) has no history of its own: its turns are recorded under the owner's id, and all three verbs, given the child's --task-id, act on the owner's checkpoints. workspaceTaskId in each reply names that task (it equals taskId for an owner). When the owner is deleted while a child remains, the heir takes over the directory and its history.

  • checkpoint --task-id ID: take one now. Captures tracked edits and untracked files, minus gitignored ones, and never touches the branch, HEAD or index. Returns { taskId, workspaceTaskId, created, checkpoint, uncaptured? }; created: false means the tree equals the latest checkpoint, which is returned instead. uncaptured names nested repos and submodules, whose contents a checkpoint cannot hold.

  • rewind --task-id ID --to N [--force]: restore the worktree files to checkpoint N. Files only: the branch, HEAD and the engine's conversation are not rewound, and the index is reset to HEAD. Files created after N are deleted, files deleted since are restored, gitignored files are left alone. The current state is checkpointed first, so a rewind is undoable: undo.n in the reply is the number to rewind --to to get back. Returns { taskId, workspaceTaskId, target, undo, uncaptured }. Refused with TASK_BUSY while an engine of ANY task sharing the workspace (the owner or a child) is running or permission_needed, because it would rewrite files under the agent; the error names the busy tasks, and --force rewinds anyway. CHECKPOINT_NOT_FOUND for an N that was never taken or has been pruned. A layerfs task rewinds its whole tree, .git included, and refuses with WORKSPACE_BUSY while any terminal is open in it.

  • export-branch --task-id ID: copy a layerfs task's git branch out of its sandbox into the host repo under the same name: a git bundle of the commits past the task's starting point, fetched without force (a host branch that has moved elsewhere refuses). Only commits travel, so commit inside the task first. Land, push or open a PR from the host repo afterwards. Returns { taskId, workspaceTaskId, branch, commit }; EXPORT_UNSUPPORTED for any other task, whose branch is already in the repo.

  • worktree-usage [--task-id ID]: the disk each task's own worktrees take, largest first, archived tasks included — which ones remove-worktree would free most. Read-only. Only checkouts a task owns count (its worktree and each extra repo's): a project's own checkout, a directory task and a shared child are never listed, and a task whose worktree is not on disk is left out. Returns { tasks, bytes, ignoredBytes }, each row { taskId, title, branch, archived, status, updatedAt, bytes, ignoredBytes, checkouts: [{ path, exists, bytes, ignoredBytes }] }; --task-id returns that one row, or NO_WORKTREE for a task that owns none. ignoredBytes is the gitignored part, including dependencies, build output, user notes and local configuration. It is not a measure of disposable content. exists: null means the checkout could not be reached or its presence could not be confirmed; it stays in totals as unknown. A null size means it could not be measured, never zero; such a row sorts last. Sizes come from du, so on macOS a dependency folder Rove cloned into the worktree counts in full even while it still shares blocks with the main checkout's copy.

  • discover-adoptable --repo PATH: list existing git worktrees not yet tracked as Rove tasks. Returns { worktrees, unreadable }. The repository's own primary checkout is never offered — not even when PATH is one of its linked worktrees, where the caller and the main checkout are different directories. adopt validates against this same list, so it refuses the primary checkout by name. unreadable is the admin-dir names under <repo>/.git/worktrees/ that git worktree list omitted without an error — a worktree that exists on disk (uncommitted work and all) but cannot be read, and so cannot be adopted until the permissions are fixed. worktrees: [] with unreadable: [] is the only combination that means "this repo has nothing to adopt".

  • adopt --repo PATH --worktree PATH [--branch B] [--command CMD] [--title T]: import an existing git worktree as a Rove task.

feedback

  • feedback --title T --body TEXT [--kind bug|idea|question] (offline): file a GitHub issue on the Rove repository via gh issue create, labelled bug, enhancement or question (default bug), with an environment block (version, OS, install kind, default engine) appended to the body. Returns { ok, filed: true, url, number }. When gh is missing or signed out it returns { ok, filed: false, url, hint }, where url is a prefilled new-issue link to open and hint says to run gh auth login.

github

GitHub sign-in per machine. Rove keeps no token: a machine's sign-in is its own gh login (its keychain), and "Rove's account" is the hub's. These verbs run in the daemon, so the device-flow child and its pending state live in one place; any --machine runs them in that machine's daemon.

  • github-status (group read): can this machine use GitHub? Returns { git, gh, account, host, protocol, scopes, gitUsesGh, ssh, login }: git and gh are versions or null; account is the signed-in gh login or null (authDetail says why when gh has a login it cannot use); protocol is gh's git_protocol; gitUsesGh is whether git's credential helper for https://github.com runs gh auth git-credential; ssh is { ok, user?, detail? } from ssh -T git@github.com with StrictHostKeyChecking=yes, so an unknown host key is reported, never accepted; login is the pending device flow ({ state: "pending" | "syncing" | "failed", code?, url?, error? }) or null. A missing tool is a value, never an error. Every probe is bounded.
  • github-login [--cancel] (group edit, inbound class credentials): start the gh auth login --web device flow on this machine. Returns { state: "pending", code, url }; type the code at the URL and poll github-status (.login, then .account). One flow per machine: a second call returns the pending one. --cancel stops it ({ cancelled }). The flow is a child of the daemon and ends with it. When it completes, gh auth setup-git runs and, if the flow was started on this machine, every registered machine that is online, has gh and is signed out receives the same account. A machine already signed in as someone else is left alone.
  • github-logout (group edit, class credentials): gh auth logout for the active github.com account on this machine. It signs gh out for every tool, not only Rove. Returns the new github-status.
  • github-sync --to ALIAS (group edit, class credentials): give a registered machine this machine's account. The daemon reads its own gh token in memory and sends it to ALIAS as a parameter of github-apply; nothing prints or stores it. Returns that machine's new github-status. Named --to because --machine would run the verb there. Refused (GITHUB_SYNC_FORWARDED) when another machine's hub sends it.
  • github-apply --token TOKEN (internal; class credentials): what github-sync sends. Refused unless forwarded from another machine. The token goes to gh auth login --with-token on stdin, never argv; the machine's git_protocol is kept (unset reads as https), then gh auth setup-git runs. Not for direct use.

On this page