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.
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, ui-prefs, 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 its outcome
back to the dispatching engine tab: creation records the dispatcher
(task + tab), so a bare send routes home without any id in hand; no
stored report, 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 terminalFan 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 branchOutput + exit-code contract
- Success → one JSON object on stdout, newline-terminated, exit 0.
--prettyindents it (humans only). - Error →
{ "error": { "message", "code", ... } }on stderr. Common rejections additionally carryhintandnextCommandArgs(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,GIT_COMMAND_FAILED,BAD_EVENT_KIND, theEMPTY_BRANCHpair — incode, not in the prose.RPC_ERRORnow means what it says: the daemon failed without naming a reason. Never match onmessage; it is written for a human and it no longer repeats the code. - Exit codes:
0success ·1handler/RPC failure ·2usage errors (unknown verb, bad/missing flag, unreachable daemon — wherever they are raised, including a handler rejecting its own argument) ·3a parallel round that did not fully succeed. Exit 3 does not promise anything was created:--count 1takes the same path, so a lone failure returnscount: 0with an emptytasks. The full payload still goes to stdout, so whatever was created is never lost. rove api <verb> --helpprints that verb's usage and exits 0.
Codes the CLI itself raises
Separate from the daemon's refusals above — these never cross the socket:
| Code | Raised when |
|---|---|
MISSING_VERB | rove api with no verb. |
BAD_VERB | A verb (or schema --verb / --group) name that never existed. |
UNKNOWN_VERB | A verb that was REMOVED; nextCommandArgs is its replacement. |
MISSING_FLAG | A required flag was omitted. |
BAD_FLAG | Unknown flag, bad enum value, or a value the verb could not parse. |
BAD_TAB | A --tab value that is not new or tab-N. |
BAD_DAEMON | The daemon could not be reached or started. |
DAEMON_VERSION_SKEW | The daemon is a different build and does not serve this verb. |
MISSING_TARGET | No --task-id, no $ROVE_TASK_ID, no active task — nothing was named. |
MISSING_TEXT | row-token was given neither --text to write nor --clear to remove. |
TASK_NOT_FOUND | An id WAS named and does not resolve. |
TAB_NOT_FOUND | A --tab tab-N the task has no live (or restorable) tab for. |
RUN_NOT_FOUND | routine-respond --run names no routine run (unknown, or pruned from the last 100). |
RESPONSE_TOO_LARGE | A routine-respond response over the 32,000-character cap. |
NO_ENGINE_TAB | The 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. |
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_WORKTREE | The task has no materialized worktree yet. |
BASE_CHECKOUT | delete was aimed at the project's own checkout, not a Rove worktree. |
CALLER_WORKTREE | delete was run from inside the worktree it would remove. |
HISTORY_REQUIRED | read-output --source history on an engine with no history reader, or on a non-engine tab. |
HISTORY_UNREADABLE | The engine's history exists but could not be parsed. |
CURSOR_INVALID | A --cursor value this build cannot decode. |
CURSOR_TASK_MISMATCH | The cursor belongs to a different task. |
SOURCE_CHANGED | The cursor's source/session/tab moved under it. |
TAB_RESTORED | The --tab exists with its scrollback but nothing runs in it; pass --respawn. |
ENGINE_NOT_RUNNING | The tab's engine exited into a plain shell, so a paste would run as shell commands. |
ENGINE_PROBE_FAILED | The ps probe behind that check failed or blew its deadline, so the tab's engine was never read. |
DISPATCHER_UNREACHABLE | A bare send whose dispatcher tab is dead and whose task has no live engine. |
NOT_DELIVERED | The task was created but the prompt never reached its engine. |
EMPTY_SUCCESS_REPORT | A succeeded: report from a branch with 0 commits; commit, or pass --allow-empty. |
SESSION_FAILED | A hosted engine session could not be started or written to. |
BAD_EFFORT | The task's engine declares no effort levels, or not that one. |
BAD_MODEL | The task's engine declares no model flag, so a model cannot be passed to it. |
CONFLICTING_FLAGS | add --tier beside --command, --model, --effort or --agents — the tier already fills those. |
TIER_UNAVAILABLE | Auto routing is not configured, or the tier's target cannot start (engine not listed, not logged in, model/effort its engine cannot carry). |
PARTIAL_FANOUT | A parallel round with at least one failure (exit 3). |
UNSUPPORTED | interrupt on an engine that never declared how it is interrupted. |
WATCH_TIMEOUT | watch reached --timeout before any --until state; nothing has happened YET. |
DAEMON_GONE | The daemon died mid-watch; the verb never reconnects silently. |
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 — 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, archive →
delete (there is no hide-without-delete any more; the branch survives unless
you pass --delete-branch), 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 anapiVersionagents 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 onPATH, and engines contributed by enabled plugins — each with the RAW command it runs, its display name, itsprotocol(the adapter Rove speaks to it: history reads, trust pre-answer, first-message delivery;generic= none), and itsmodels— 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 itsprotocol— the plugin's manifest carries the screen rules and identity Rove drives it with, so it is notgeneric. What it prints is what a launch runs, so an entry can be copied into--commandverbatim or edited first. Returns{ engines }.
read
-
list: list all tasks. Returns{ tasks, activeTaskId }—activeTaskIdis the shared focus that verbs using the implicit target (send,pane-open,pane-close,read-outputwithout--task-id) default to;nullmeans no active task. Reading it back is the audit trail for any delivery that omitted--task-id.--repo PATH,--status S1,S2and--activity A1,A2narrow the list. Flags combine with AND; a comma list matches any of its values.rove api list --activity permission_needed,erroris every task waiting on you. With--activity, each returned task carries the.activityit matched (the same viewcollectreports); a task whose engine state Rove cannot read, such as one that never started or one on another machine, never matches.--reponever matches another machine's task, andunresolvableReposbesidetasksnames task repos it could not compare. An unknown status or state is refused withBAD_FLAG.
-
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-tabalive/engineAlive): the discovery read forsend --tab tab-N.sessionIdis the conversation the tab pinned — the exact idclaude --resume <id>(or the engine's own resume verb) reopens, and the onesend --tab tab-N --respawnbrings 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.liveVendoron a live tab is a fresh foreground process walk (which engine actually runs in the tab's shell right now. A hand-typedclaudein a shell tab counts, a ctrl+C'd engine doesn't); dead tabs report the last recorded value.aliveandengineAliveanswer different questions and a fleet reader needs both:aliveis the tab's hosted PTY SESSION,engineAliveis whether an ENGINE PROCESS is running inside that session's tree. keepAliveexecs a login shell where an engine exits, soalive: true, engineAlive: falseis a tab holding a bare zsh prompt — the per-tab form of the session-outlives-its-engine hazard described undercollect's.runningbelow, and the field that settles it in one read instead of acollecthop or your ownpswalk. Both are three-valued:nullmeans nothing walked the tab (apsthat failed, a pty host that could not be asked), which is "couldn't look" and never a verdict. Never readnullasfalse— the same rule.runningstates, for the same reason. A dead tab whose session ended abnormally also carriesexit(code/signal/at); clean exits stayexit: null. A live PTY session the persisted snapshot does not list still gets a row, markedunregistered: 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 promptadd --promptdelivered 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 pointcollectmeasures 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, andpassingis what "CI is green" means; a local test run is a different claim. Absent until the poller has seen a PR for the branch. -
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 toget-taskper task. Select the tasks by fan-out round (--group, thegroupIdthatadd --countreturns), by repo, or by explicit ids.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 aREPO_UNRESOLVABLEerror naming the path, and a task whose own repo will not resolve is listed inunresolvableReposbesidetasks. An emptytaskslist is only "this round is empty" whenunresolvableReposis absent — the same null-versus-empty distinctionpty-listanddiscover-adoptablekeep.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 livepswalk 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. Andnullmeans the pty host could not be asked at all — "couldn't look", never "nothing is running", the same distinctionpty-listpublishes assessions: null. Never treatnullasfalse: a cleanup loop that does will delete worktrees holding live work. Still process truth, not the cached status fieldlistreports, 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:idleandturn_completeare 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.notecarries 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 readserrorwithsource: "screen"instead ofidle.forMsis the "stuck for 40 minutes" number.nullwhen the registry cannot answer (daemon restarted, task never observed) — an honest unknown, never a fabricated idle..activity.statedisagreeing with.runningis itself the signal:running: falsewith apermission_neededstate 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 asget-task(pick asend --tabtarget without a second hop). A tab whose session died abnormally carries.exitwithcode/signal/at/layerandtail, 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.layersays WHICH process the record describes, without whichcodeandsignalcannot be read together."pty"is the tab's own session child, on a dead tab: a signalled session has no wait-status code, socodethen comes from the wrapper's ownEngine exited (code N)banner and belongs to the ENGINE, whilesignalbelongs 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, becausecode: 0("the human quit their agent") andcode: 143("it was SIGTERMed and there is unfinished work") are exactly what a fleet reader is trying to tell apart.atApproximate: trueon such a row meansatis when the daemon DISCOVERED the death, not when it happened — see theinspectsessionExitsnote 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: 0against a resolvedbaseRefis the "reported succeeded, committed nothing" tell.baseRefis the task's RECORDED fork point (add --base-branch) when it has one — tasks cut fromrelease/2.xare measured againstrelease/2.x, not against a guessedmain; only records predating the field (or a recorded ref that no longer resolves) fall back to theorigin/HEAD→main→masterguess.
Read-only by contract: it starts no engines, writes nothing, and changes no task state.
-
context --repo PATH [--limit N] [--text]: the coordinator's start-of-turn read. One composed snapshot of a project, meant to be run at the beginning of every turn so a coordinating agent works from what the command printed instead of from what it remembers. Composition only — five reads the daemon already answers, joined server-side.The point is the derived group. A task's
statusis a claim somebody wrote (update --status), so a worker that crashed staysin_progressforever.contextreports instead which group a task is in, derived from its report, its PR observation, its arbitrated engine activity and its tab liveness:groupmeans your move waiting-on-youpermission prompt, a quota wall with no scheduled resume, a settled error, a dead tab that delivered nothing, a failed deletion answer it landingPR open and approved merge it ready-for-reviewa reportexists, or a turn finished, and nobody actedread the diff workingan engine is producing output, or the daemon will resume it on a timer nothing idlewe looked; nothing is happening nothing unknownwe could not look look ( get-task,read-output)rankis the sort key (0 =waiting-on-you), andtaskscomes back sorted by it, so the first row is what needs a person next. Ties break on the freshest activity.unknownis 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 samenull≠falserule the rest of this page keeps.Two debounces keep the top group honest, both derived from the activity observer's own cadences: an
errormust stand 20s (an engine that fails a turn and retries by itself firesturn-failedthenturn-startseconds 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_neededandrate_limitedare 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},nullwhen unreadable),checkState(Rove's own CI truth, when there is a PR),pr, and the worker'sreportclaim. Nothing else — the verb is paid for on every coordinator turn, andworktreePath,vendor,groupIdand the declaredstatusare oneget-taskorcollecthop 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) — andnotes, 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
mainseat anddirentries are not anybody's turn, and a worktree already being removed is spent (a deletion that failed is listed, aswaiting-on-you).--limitdefaults to 20 and drops the quiet tail, reportingomittedTasks. Repo resolution followscollect. A side read that fails degrades to an empty section rather than failing the verb.--textreturns{ 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 plus routine outcomes by status. Default window 7 days. Repo resolution followscollect: an unresolvable--repois aREPO_UNRESOLVABLEerror, and unresolvable task repos come back inunresolvableReposrather 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 atotalsroll-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 onturn-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, optionalgenerationidentifying the host's current in-memory session lifetime).sessions: []means a live PTY host with nothing running;sessions: nullmeans there was no host to ask — "couldn't look", not an idle fleet. Never readnullas "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--tabit reads the worktree's NEWEST engine session — once a task has a second engine tab, that can be the second tab's; pass--tab(ids fromget-task.tabs[]) to read one specific session.The first page is the NEWEST: the last
--limitmessages (history; default 40, max 50) or the last--limitterminal lines (terminal; default 40, max 200).history.firstIndexis the transcript index ofmessages[0].cursoris 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.olderCursorpages backward through history (nullat the start of the transcript, and alwaysnullfor terminal). A cursor is pinned to one source/session/tab and returnsSOURCE_CHANGEDif that moved.--tab tab-Nreads 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 — withfallbackReasonhistory_missingorengine_unsupportedfor 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 historyreturns its empty page), andnullfor shell/command tabs.--source historyon a non-engine tab isHISTORY_REQUIRED;TAB_NOT_FOUNDwhen the tab has no session. A dead session's terminal page includesterminal.exit(code/signal/at) while the PTY host still runs. A history page also carriesengineErrorwhen 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
watchwith 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 sinceRepeat the last two steps (each response's
cursoris the next poll point). The CLI never attaches to the session; this is how an agent follows one without taking it over. -
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 acollectpolling 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 withBAD_FLAG— for a verb whose whole job is to wait, a typo that waits forever is the worst possible failure.deadis the one polling is worst at. ASIGKILLed engine fires no hook, so the daemon writesdeadfrom 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--untilstate matches immediately, which is the intended answer.Exit codes:
0withmatchedon a hit;WATCH_TIMEOUTwhen--timeout(default 300000 ms) elapses first — that says nothing has happened yet, never that nothing will;DAEMON_GONEwhen 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, pluscontextUsage— the collector's current reading per live engine session, keyedtaskId::tabId, carryingcontextTokensand the session'sinputTokens/outputTokens/cacheReadTokens/cacheCreationTokenswhere 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 carryexit),sessionExits(durable death records, newest first: exitcode/signal/atplus a plain-text outputtail, kept inpty-exits.jsonso 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 addsparentAliveplusvendorwhere 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'sEngine exited (code N)banner still in the session's ring; those records carryatApproximate: true, meaningatis discovery time and no vendor, because nothing on disk records either), andtabs(the snapshots the sidebar names its rows from, reconciled against the live session inventory: a task whose snapshot is missing an alive<taskId>::tab-Nsession reports those tab ids underunregistered, 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 tonull. Run and paste this first when reporting a badge, label, engine-identity, or engine-crash bug.
create
-
add --repo PATH [--title T] [--branch B] [--base-branch B] [--worktree-name NAME] [--command CMD] [--count N | --agents claude:2,codex:1] [--status S] [--pin] [--activate] [--prompt TEXT | --prompt-file PATH]: create a task (appears in the sidebar immediately). With--promptit also materializes the worktree, starts the engine, and delivers the prompt. 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..warningsnames a--modelthat plainly belongs to another vendor (codex handed aclaude-*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-flowin a type-prefixed repo,login-flowin a bare-slug or empty repo; name collisions get a short-2/-3suffix, 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 totask-<last 6 of the task id>, so two such tasks get two distinct names instead oftaskandtask-2. An explicit--branchis checked againstgit check-ref-format --branchBEFORE the task is created; a name git would refuse isINVALID_BRANCHand nothing is written.--repois resolved to the repository root, so a path pointing at a SUBDIRECTORY is accepted and climbs. When it does, the result carriesrepoResolvedFromwith the path you passed — absent when--repoalready named the root.A
--titleis 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-namenames 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 withget-taskafterwards. 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 withWORKTREE_NAME_TAKENand never silently suffixed-v2, because a caller that asked forprobe-1and quietly gotprobe-1-v2looks in the wrong place and finds out somewhere else. Single task only, like--branch.--base-branchcuts the new branch from that ref instead of the repo's current HEAD and is persisted on the task (.task.baseRef) — the fork pointcollectmeasures against, durable across daemon restarts. The delivered--prompttext 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.Parallel attempts live here too:
--count Nspawns N sibling tasks of the SAME prompt, each with its own worktree and branch, sharing onegroupIdand#i/Ntitles;--agents claude:2,codex:1does the same with a mixed fleet (engine IDS only; a raw command line can't be expressed per-sibling; issue N separateadd --commandcalls 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);--agentsalso rejects--countand--command(it already names each sibling's engine). This was thefan-outverb, which no longer exists.--commandpicks the engine (an id fromengine-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 LEVELand--model MODELpin the engine's reasoning level and model for the first session onward; both are gated per engine (BAD_EFFORT/BAD_MODEL), and--modelis passed verbatim in the engine's own spelling.--tier swift|standard|deepfills 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).--tier autoasks a classifier to read--promptand pick the tier for you. It needs a prompt (BAD_FLAGwithout one) and it is off until you configure it — seeautoRouting.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.--repoaccepts pathsrove addrefuses — a checkout under.scratch/,.dev-sandbox/or$TMPDIRgets a task here and getscannot be a project — inside a sandbox or scratch directorythere. The two verbs ask different questions:rove addregisters a PROJECT, and the eligibility gate exists to stop throwaway checkouts becoming permanent sidebar rows.add --repocreates a TASK, and the same gate still runs — it just skips minting the project row and thesavedReposentry 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.
drive
-
send [--task-id ID] (--prompt TEXT | --prompt-file PATH) [--tab TAB] [--command CMD] [--plain] [--allow-empty]: paste a follow-up into a task's running engine (one full turn).--prompt-filereads the text from a file (-= stdin) so the shell never sees it: backticks inside a double-quoted--promptare command substitution, and a message that names a reply command (`rove api send …`) ships that command's OUTPUT instead of the words.addanddispatchtake the same flag. Without--task-id, a task that has adispatcheron 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>);--plainskips that prefix.delivered: truesays the bytes landed and nothing more: when the target was already inerrorordead, the result carriestargetState(andtargetDetail), so a prompt sent into a failed session is visible as such.--tab newspawns a fresh engine tab, while--tab tab-Ntargets that exact tab (TAB_NOT_FOUNDif it is dead or absent). A tab a pty-host restart froze is neither: it is listed bypty-listwith its scrollback and launch command intact, and it refuses withTAB_RESTOREDuntil you pass--respawn.--respawn(valid only with--tab tab-N) revives that tab in place and then delivers, replyingrespawned: true. A tab with asessionIdcomes 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. WhensendSTARTS a new session (started: true) while the task has freeze-restored engine tabs it did not use, the reply carriesfrozenTabs—{tab, sessionId}for each — because otherwise "opened a blank agent while your two real conversations are frozen" reports exactly like a healthy first start, andget-taskthen saysrunning: true.--command CMDis valid only with--tab new: it pins that new tab to that engine without changing the task's own. Using it with an existing tab is aBAD_FLAGerror rather than a silent switch. Delivery needs a live engine in that tab: one that exited into the keep-alive shell refuses withENGINE_NOT_RUNNINGand a--tab newhint instead of pasting into a shell. When thepsprobe behind that check fails or blows its deadline the refusal isENGINE_PROBE_FAILEDinstead — 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. Without--tab, the canonical target is a live engine tab (tab-1first, then any surviving engine tab); when live tabs exist but none resolves as an engine,sendrefuses withNO_ENGINE_TABrather 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: truein the result marks that fresh session (vs. delivery into an existing one).The prompt is pasted and submitted, unconditionally. Rove does not read the target composer or wait out a quiet keyboard first: a
sendthat 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 withEMPTY_SUCCESS_REPORTand 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'sEMPTY_BRANCHcatches 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 definiteahead === 0— an unresolvable base readsnulland never refuses.--allow-emptystates 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 withengineReady: 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.
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;
engineReadythere reports the engine PROCESS being found inside the session, and nothing else. A hosted session stays alive after its engine exits — the wrapperexecs 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 asSESSION_FAILED, carrying the already-createdtaskId, thesessionkey, and the session's own last line asreason. The one non-failure that reportsengineReady: falseis a repo whose.rove/init.shis still running: it precedes the engine, sodeliveredstaystrue(the prompt is still riding an argv that has not run yet) andreasonsays so, rather than holdingaddopen for the length of an install. -
interrupt --task-id ID [--tab TAB]: 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 fromtab-closeanddelete.It exists because the escalation had a hole in the middle. When a worker runs away,
sendcannot 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-closethrows the conversation away,deletethrows 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 (everygenericprotocol engine) is refused withUNSUPPORTEDrather than guessed at:Escandctrl-Cmean opposite things across engines, and one of the two guesses quits the process.Returns
{ taskId, tabId, vendor, interrupted, bytes }.interruptedreports the WRITE, not the effect — an engine acknowledges an interrupt on its own screen and its own schedule, so readcollect's.activity.statefor what actually happened. -
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). Unlikesendit never starts an engine — it needs a session that is already hosted.--tab tab-Ndelivers into exactly that tab instead of the canonical engine tab. The result'sdeliveredis the verdict:true— the daemon pasted the text into a live engine session, andtabIdnames which tab took it.falsewithreason: "broadcast"— no hosted session answered, so the text went out on thesession.deliverchannel for a browser-hosted session to pick up. Nothing can confirm that paste;clientsis a raw connection count (the calling CLI is one of them) and only its0proves 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 worktree session on this repo starts with it in its system prompt; and forwarded to the dispatcher session for live relay to in-flight tasks. -
note-list --repo PATH: read a repo's accumulated field notes, newest first. Returns{ notes }, each carrying theidnote-deletetakes. 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 };falseis 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. -
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-Nhosts the split in that tab instead) or open a separate command tab.--commandruns via your login shell (-ilc, so shell-rcPATH/exports apply, same as the engine tab) and the pane closes when it exits; omit it for an interactive shell. Broadcast over the daemon'stab.openchannel, so an attached TUI showing the task performs the split (headless, nothing happens). The result carries the resolvedtitle— the labelpane-close --titlemust match, derived from the command's first word when--titleis omitted — andclients, the attached-connection count:0means nobody performed the split, so an agent must not report "pane opened" on that verdict. (The calling CLI is itself one connection, so1does not prove a TUI is listening;0is the unambiguous case.) 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-Nscopes the match to one tab. Engine panes are never closed. Broadcast over the daemon'stab.closechannel; an attached TUI performs the close (headless, nothing happens). The result'sclientsis the reach signal:0= no attached TUI performed the close (same semantics asdispatch's). -
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: itstty(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) andcellWidth/cellHeightin 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.wrotereports whether anything was broadcast, andclientsis the usual reach signal.ok: falsewithunsupportedis a real answer, not an error:no-cell-sizemeans no attached terminal could measure a cell (it declinedCSI 16 t), andmixed-cell-sizemeans 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 byget-taskin.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 removes the persisted tab snapshot 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 returnsTAB_NOT_FOUNDwith aget-taskrecovery command. -
notify --title TEXT [--body TEXT] [--kind KIND] [--task-id ID] [--source TAG]: show a toast in every attached Rove UI.--bodyadds a second, muted line under the title — context, not a second message.done/needs_input/errorget severity styling; any other kind renders neutrally. The result'sclientsis 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 }. -
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 theengine.reportEventRPC 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), plustool-pre/post/failed,pre/post-compact,subagent-start/stop(plugin-only).
edit
-
update --task-id ID [--title T] [--branch B] [--command CMD] [--model M] [--effort LEVEL] [--pinned true|false] [--status S] [--report-branch B] [--report-pr N] [--report-summary TEXT]: change any combination of a task's fields in one call. Every field is validated before anything is written, then applied in a fixed order: branch → command → model/effort → title → pinned → status. The call is not atomic: if a step fails midway, the error reports the fields alreadyapplied. The result is{ ok, taskId, updated, command?, protocol?, generic?, engine? }, whereupdatedlists the fields written, in order.--title T: the task's title. With--tab TABit names one Terminal Tab instead (the API twin of the TUI's f2) and--titleis the only other flag allowed.TAB_NOT_FOUNDwhen the task's snapshot names no such tab —get-tasklists the addressable ids in.tabs[].id.--branch B: rename the task's branch (git branch -mif materialized, else recorded).--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, andgeneric: truewhen the command names no engine Rove knows.--effort LEVEL: reasoning effort (next session rebuild). Levels are declared by the task's engine — codex acceptsnone,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'smodelsare suggestions. Rejected (BAD_MODEL) when the task's engine declares no model flag.--pinned true|false: pin/unpin the task to the top of the sidebar.--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.reporton the task ({ branch?, pr?, summary?, at }), readable fromget-taskandcollect.
.reportis a CLAIM;.prStatusis an OBSERVATION. The daemon polls the forge forprStatus.number/prStatus.checkState;report.pris 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-prkeeps the branch reported earlier. Anupdate --statuswith no--report-*flag writes no report at all — an empty one would restampatand claim the worker reported again.The old per-field verbs are gone (no aliases): calling one returns
UNKNOWN_VERBwith theupdateform innextCommandArgs.
issues
The daemon-owned issue store (backlog; see
WORK-TRACKING.md). Statuses: open, doing, hold,
done.
issue-list --repo PATH: list a repo's issues.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 noneunlinks) and/or set the status. Title, body and link land in one store write, so a rejected--taskleaves them unchanged — the error means nothing was applied.--statusfollows as its ownsetStatuschange (the op pluginissue.changedevents 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'sdruns, which the CLI could not reach: an agent asked to clear a batch of stale stories could previously only mark themdoneand leave them. Useissue-update --status donewhen 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. 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] [--label L]: list issues.--assignee @mefor your own.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 alinkedWorkItempointing 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. 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 --name N (--prompt TEXT | --prompt-file PATH) --schedule CRON [--vendor V] [--model M] [--effort LEVEL] [--base-branch B] [--precheck CMD] [--precheck-timeout SEC] [--grace MIN] [--persistent-session] [--disabled] [--target-task ID --target-tab TAB]: schedule a prompt.--scheduleis five-field cron in the daemon host's local time ("0 9 * * MON-FRI").--model/--effortpin every run's engine, validated at save time with the sameBAD_MODEL/BAD_EFFORTasaddagainst--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;--enabled true|falsepauses / resumes. A new--schedulere-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 ''sendstarget: nullto clear it. While a routine retains a standing task, vendor/model/effort changes and clears are rejected even if its engine has exited. Disable it and create a replacement with the desired pins. Unchanged pins and other field updates remain supported.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. Each delivered routine prompt starts with a[ROVE ROUTINE] "<name>" run #<n> — when done, report with: … routine-respond --run <runId> --prompt-file -line, then the prompt. One response per run, a second call replaces it; cap 32,000 characters (RESPONSE_TOO_LARGE, never truncated); an unknown run id isRUN_NOT_FOUND. No response is ever inferred from a transcript.routine-runs --id ID: run history, newest first. Answered runs carryresponse: { text, at }.reviveddescribes a respawned standing session;skipped_cancelledmeans disabled, changed or stopped before delivery. Bound deliveries includetaskId/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. Its task is folded behind the
sidebar's N routine sessions count row (still findable by search, still
Inbox-reachable). 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 had exited, so it was respawned in the same worktree; the
files carried over, the conversation did not).
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
-
land --task-id ID [--dry-run] [--strategy merge|squash] [--delete-branch] [--remove-worktree=false]: merge a task's branch back into its base repo's current branch (--no-ffmerge, 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_WORKTREEwhen 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=falseto keep it. The branch always stays either way (pair with--delete-branchto 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'sworktreefield ({ removed, reason? }) instead of failing the land.--dry-runanswers "may this land, and into what" without writing. It returns{ branch, landedOn, ahead?, baseDirty?, refusal?, dirtyFiles?, baseDir }:landedOnis 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 — andaheadis how many commits would land. When the land would be refused,refusalis one ofDETACHED_HEAD,UNREADABLE_BASE,UNBORN_BASE,SAME_BRANCH,MAIN_CHECKOUT_DIRTY,MISSING_REF,EMPTY_BRANCH,EMPTY_BRANCH_DIRTY_WORKTREE, andmessagecarries 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: 0is the empty-merge that otherwise only surfaces at land time.--delete-branchneeds the worktree gone. git refuses to delete a branch a live worktree has checked out, so pairing--delete-branchwith--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 inbranchKept({ reason }) and writes nobranchAnchor. -
delete (--task-id ID | --group GROUPID) [--force] [--delete-branch] [--delete-remote] [--wait]: remove a task and its worktree. The git branch stays unless--delete-branchis passed; git is the durable record, the task row is not. Needs--forceon a dirty worktree;--forcenever implies--delete-branch.--delete-branchdeletes the LOCAL branch by git's own rules.git branch -drefuses 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.--forceupgrades the delete togit branch -Dand takes the unmerged case too. The removal succeeds either way, by design.--delete-remoteis a separate opt-in that--delete-branchnever 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 pushesgit push <remote> --delete <branch>to the branch's own configured remote (branch.<name>.remote), falling back toorigin.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: falsecomes withkeptReason— 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 fromstatus: "removed", which is the worktree's outcome and never the branch's.branch.remote.deleted: falsecarrieserrorthe 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 logsbranch kept task <id> branch=<name> — git refused the delete: <reason>to~/.rove/daemon.log, next to theremoved …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. Seedocs/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 }withstatuseitherqueuedornot_found.queued: falsemeans 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.--waitfollows the deletion to its outcome and reports it:removed— the worktree and the task row are gone.failed— removal failed anderrorcarries git's own message (a locked or non-empty directory, a permissions problem). The task KEEPS its row, withdeletion.phase: "error", so it stays visible and re-deletable. Before this existed the failure reacheddaemon.logand 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 withlistrather than retrying the delete.
A deletion's state is also readable at any time from
list: the task'sdeletionfield carriesphase(queued/running/error) and, onerror, the message.--group GROUPIDcloses a whole fan-out round in one call, selecting by the samegroupIdcollect --grouptakes (the oneadd --countreturned). 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.
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, soensure-worktreecan materialize it again. This is what a script reclaiming idle checkouts wants;deletetakes 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 intorefs/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) andCALLER_WORKTREE(the directory the command is running from).NO_WORKTREEwhen the task never materialized one.residuemeans 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. -
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 whenPATHis one of its linked worktrees, where the caller and the main checkout are different directories.adoptvalidates against this same list, so it refuses the primary checkout by name.unreadableis the admin-dir names under<repo>/.git/worktrees/thatgit worktree listomitted 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: []withunreadable: []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 [--category SLUG](offline): create a GitHub Discussion in the Rove repository's Feedback category viagh.