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:
| Transport | Call | Answer |
|---|---|---|
| CLI | rove api <verb> --flag value | the result on stdout, or { "error": … } on stderr (below); --envelope prints the envelope instead |
| Daemon socket | request 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 isBAD_FLAG. Without--task-id, no$ROVE_TASK_IDapplies either: the target is the active task. - No stdin.
--prompt-file -isBAD_FLAG; pass--prompt. - No streaming.
watchandpane-graphics(cliOnly: trueinschema) answerCLI_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.addis the exception:add --machine <alias>keeps the agent on this machine and puts only its worktree and commands there (add --workspace-on, seeaddbelow), unless--engine-thereis passed. Over the socket or the bridge, pass"machine": "<alias>"besideverbandparams.localnames this machine.autoplaces 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--repoit can compare,autoonly considers machines that have that repo (the stderr line lists the ones skipped); when none has it, load alone decides.--clonelifts the filter, since any machine can fetch the repo.- A
--task-idthat lives on another machine runs on that machine without--machine, so a remote task the sidebar shows answerssend,read-output,deleteand 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-fileis read here and sent as--prompt.--repois 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) isFORK_MATCHunless you pass--allow-fork; no checkout at all isNO_REMOTE_CHECKOUT, or, with--cloneand a repo that has an origin, the target clones it. - A forwarded call is never forwarded again, and
listover 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 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,TASK_BUSY,MAIN_TASK,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, or a tickshould-run/send --if-runnablerefused (the verdict is on stdout). Exit 3 on a parallel round 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.--envelopeprints 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:
| 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. |
DAEMON_UNREACHABLE | The daemon socket refused the connect (restarting or stopped); the request never left, so a retry is safe. |
CLI_ONLY_VERB | api.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_UNREACHABLE | The 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_MATCH | The 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_CHECKOUT | add --repo names a repo id and this machine has no checkout of it. Pass --clone, or --repo with a checkout's path. |
CLONE_FAILED | add --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_DENIED | The target machine's inbound policy refuses this verb's class from other machines (rove machine inbound). |
PUSH_FAILED | publish (or land of a remote task) could not push the task's branch. |
MACHINE_VERSION_SKEW | That machine's Rove predates forwarded verbs; upgrade it. |
MISSING_TARGET | No --task-id, no $ROVE_TASK_ID, no active task — nothing was named. |
NOT_RATE_LIMITED | resume-quota --task-id on a task that is not waiting on a quota reset. |
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. |
TAB_COLLISION | tab-new found its freshly allocated tab id already running a session; no new tab was created, retry. |
FILE_NOT_FOUND | tab-new --kind editor --file names no file in the task's worktree. |
NO_EDITOR | tab-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_FOUND | tab-new --kind pane --pane names no pane of an enabled plugin. |
TAB_NOT_SPLITTABLE | tab-split on a content tab; only terminal tabs split. |
LAST_LEAF | tab-split --action close on the only pane; close the tab instead. |
RESET_ENGINE | tab-split --action reset on a pane that runs an engine; only shell panes reset. |
TAB_NOT_REVIVABLE | tab-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_FOUND | routine-respond --run names no routine run (unknown, or pruned from the last 100). |
RESPONSE_TOO_LARGE | A routine-respond response or a report --summary over the 32,000-character cap. |
DELIVERY_NOT_FOUND | report --delivery names no delivery (unknown, or pruned past the newest 50 for its task). |
GATE_NOT_FOUND | gate-answer / gate-withdraw names no gate. |
GATE_NOT_OPEN | The gate is already answered or withdrawn. |
BAD_OPTION | gate-answer --option is not one of the gate's option keys (or the gate is free-text only). |
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. |
NOT_A_GIT_REPO | One of several --repo values is not a git repository (the message names it). |
REPO_DUPLICATE | add names the same repo root twice; a subdirectory counts as its root. |
TOO_MANY_REPOS | add names more than 8 repos for one task. |
REMOTE_EXTRA_UNSUPPORTED | A 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_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. |
NO_CONVERSATION | send --handoff-from tab-N where that tab holds no conversation to hand over yet. |
TAB_ALIVE | send --handoff-from tab-N --replace where that tab's engine is still running; only a dead or exited one is replaced. |
NO_TRANSCRIPT | send --handoff-from tab-N where the tab's engine keeps no transcript Rove can name (a custom engine). |
ENGINE_NOT_RUNNING | The tab's engine exited into a plain shell, so a paste would run as shell commands. |
AWAITING_PERMISSION | send 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_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: send, or report --outcome done on a send delivery, from a branch with 0 commits; commit, or pass --allow-empty. |
SESSION_FAILED | A hosted engine session could not be started or written to. |
ENGINE_LIMIT | Starting a session would pass engine.maxLiveSessions; nothing was started. |
ENGINE_ADMISSION_UNAVAILABLE | The running PTY host predates atomic admission; update and restart it before a capped launch. |
ENGINE_ADMISSION_BUSY | The session inventory kept changing during admission; nothing was started, so retry the returned command. |
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. 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_SESSION | add 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_REPO | add under a dir task that is not inside a git repository (add --isolate-in too). |
NOT_A_GIT_REPO | update --project PATH where PATH is not inside a git repository. |
SAME_PROJECT | update --project names the project the task already belongs to. |
CHILD_NOT_MOVABLE | update --project on a child task: move its parent instead (children follow), or update --parent none first. |
REMOTE_MOVE_UNSUPPORTED | update --project where the task or the target project lives on another machine. |
NOT_A_LINKED_REPO | add --isolate-in names something that is not a top-level symlink to another git checkout. |
REMOTE_PARENT_UNSUPPORTED | add under a task that lives on another machine's project, or update --parent with either task on one. |
PARENT_NOT_FOUND | update --parent or add --parent names no task on the task's own machine. |
MISSING_TARGET | add --no-worktree or --isolate-in with no --parent and no verified calling session. |
PARENT_IS_SELF | update --parent names the task itself. |
PARENT_CYCLE | update --parent names one of the task's own descendants. |
PARENT_IN_GROUP | update --group G --parent ID where ID is itself a task of round G. |
TASK_NOT_MOVABLE | update --parent on a project's main task or a routine session. |
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). |
SKIPPED | should-run or send --if-runnable refused the tick (exit 3). The verdict is the stdout payload, not an error envelope. |
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. |
GITHUB_SIGNED_OUT | github-sync or github-logout on a machine whose gh has no github.com login. |
GITHUB_GH_MISSING | A github-* verb on a machine without gh. |
GITHUB_LOGIN_FAILED | The device flow printed no code, gh exited without signing in, or gh refused the token github-apply was given. |
GITHUB_LOGIN_CANCELLED | A pending github-login was cancelled before its code appeared. |
GITHUB_LOGOUT_FAILED | gh auth logout failed. |
GITHUB_SYNC_FORWARDED | github-sync sent by another machine's hub: an account is only shared from the machine that owns it. |
GITHUB_APPLY_FORWARDED_ONLY | github-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 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.--statusprobes each engine on this machine and addsinstalled(its CLI is there) andready(installed, and signed in where Rove can read the login); with--machineit 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 ashost/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 }:repois null when nothing matched,forknames a fork that was skipped,checkoutsevery match. A hub asks a machine this before forwardingadd --repo.repo-list: this machine's repos by id (host/owner/name, orroot:<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 }—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. A multi-repo task also carries.repoCount(all repos, primary included); one-repo tasks omit it..prStatusis the primary's PR; each extra's is onget-task's.task.extraRepos[].prStatus(Rove polls every repo against its own worktree, branch and remote), and the sidebar chip,contextandcollectshow one rolled-up status — any failing check wins, then pending, then open, then merged.--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.- Archived tasks (
archive) are left out;--archivedlists only them, and combines with the other flags the same way. --fields F1,F2keeps 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, plusrepoCountandactivity; a key a task does not carry stays absent. Filters run first, so--activity running --fields id,activityworks.activeTaskIdandunresolvableReposare untouched. A misspelled key is refused withBAD_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-tabalive/engineAlive/liveTitle): the discovery read forsend --tab tab-N.liveTitleis the live session's own window title (the engine's name for the conversation), null when it is dead or set none;lastTitleis only recorded while a TUI has the tab open.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. A cancelled run counts as neither: the other checks decide, and a PR whose only runs were cancelled readsnone. Absent until the poller has seen a PR for the branch.runnabilityis theshould-runverdict 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 toget-taskper task. Select the tasks by fan-out round (--group, thegroupIdthatadd --countreturns), by repo, or by explicit ids. Each task also carriesrunnability(theshould-runverdict). For a task with extra repos each entry also carriesrepos[]—{ name, branch, worktreePath, changes, base }per repo, primary first; the top-levelchangesandbasestay the primary's, and one-repo tasks have noreposkey.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 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
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 (a visit that only focuses a task no longer counts) 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 task's own conversation, never a sibling's in a shared directory; 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. -
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}.reasonisother-machine(the conversation lives there) orengine-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 withrgwhen it is onPATH(an in-process scan otherwise), then only matching sessions are parsed; past about 1.5 s of parsing it stops and setstruncated: 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 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. -
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 };urlishttp://localhost:<port>for a wildcard or loopback bind.--alllists every listener, each with its owningtaskId(null= no task).ports: nullmeans the scan could not run (Linux reads/proc, macOS runslsof), 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 }] }.surfaceistui,web(a browser page) orphone;panelis true only for a web page with a side panel (desktop width);panelOpenandnarroware what the page reported;localis 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-idkeeps 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 }.PATHis worktree-relative and its real path (symlinks resolved) must stay inside the worktree (BAD_ARGSotherwise); another file type isNOT_PREVIEWABLE, a larger fileTOO_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 }] };baseisnullwhen 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).workingis the uncommitted diff,branchthe commits since the base,taskboth together with untracked files (the whole task against its base; with--path ., one diff of everything).--ignore-whitespaceruns the diff asgit diff -w, so whitespace-only changes drop out.--base REF(with--scope taskonly) 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 asBAD_REF.PATHis 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 (totalUnresolvedcounts 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.lineis the line in the current diff, the original line once the code has changed (outdated: true), ornullfor a file-level comment.prNumber: nullmeans the task has no PR yet. The daemon asks GitHub throughghin 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 (seereport). Each entry hasid(d-<n>),source(routine,send,gate-answer),tabId,promptHead(first 200 characters),at, andstatus:awaiting,reported,missingorskipped. A reported entry carriesreport({ outcome, summary, evidence[], next?, at }). A missing one carriesmissing({ cause, note?, at });causeisturn-complete(the turn ended with no report),turn-failed(noteis the failure detail),interrupted(the turn was interrupted),session-end(the engine session ended),engine-dead(noteis 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;noteis the error), orstalled(awaiting for over 10 minutes while the tab was neither running nor at a permission prompt;noteistab 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-startbegins 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 areport_missingInbox row (none forrate-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 latereportor dismissing the row clears it. A routine delivery whose turn completes unreported isquietinstead (nothing to report): no Inbox row, and it does not hold the next run. A routine turn that reported or wentquietraises noturn_completeInbox row. A loop prompt can readdeliveries --limit 5instead 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 exits0, or{ run: false, reason, detail?, until?, gateIds? }and exits3(the verdict is on stdout in both cases). The first matchingreasonwins, 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 latereport; skipped ticks and a delivery the rate limit or atransient-failureended do not count: the next tick is the retry);quota(rate_limited;untilis 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 stickyerror, billing and auth included; atransientfailure is not one);engine-dead(an exact--tabwhose engine exited);awaiting-permission;busy(a turn is running, the newest delivery still awaits its report, or asend --if-runnableis mid-paste). A task whose only gate is answered but undelivered is runnable: the next tick carries the answer. Without--tabthe whole task is judged (any running tab is busy) and a dead engine is not a skip, sincesendrevives it;--tab newjudges only task-level state. Reasons that need a person clear when they act: answering or withdrawing the gate, a latereportor dismissing thereport_missingrow (TUI, bridge or weixin), the engine's nextturn-start. The daemon's routine runner and the quota-resume timer ask the same question.get-taskandcollectcarry the same verdict asrunnability.
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--promptit also materializes the worktree, starts the engine, and delivers the prompt. With--startand no prompt the engine starts idle in the task's owntab-1(started: true,tabId,session); a refusal such asENGINE_LIMITkeeps the task and names it intaskId. A--promptstart that fails because the disk is full (git'sNo space left on device) removes the task it had just created: nothing was cut or started, so the sameaddworks once there is space.--startbeside--prompt,--countor--agentsisCONFLICTING_FLAGS. A bareaddis refused for every caller (EMPTY_SESSION):addsends an agent to do something, so say what —--promptfor work,--startfor an idle engine a person will type into, or--worktree-onlyfor a checkout that is not a session. With--worktree-onlythe 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;listand the Worktrees page still show it, and deleting it removes the worktree. It conflicts with--prompt,--start,--count,--agentsand--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..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.--fork TASK_IDmakes 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.workspaceis{"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,--countand--agentsconflict with it (CONFLICTING_FLAGS). The title defaults to<original title> (fork), not the prompt's first line. Refused withFORK_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>isadd --workspace-on <machine>unless--engine-thereis passed or the request is one--workspace-oncannot 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,--branchand--base-branchapply there), and the engine starts on this machine in a link directory (.task.workspaceis{"backend":"remote"}). The reply addsmachine,remoteTaskIdandremotePath. See Run the agent here, keep the work there. It conflicts with--count,--agents,--fork,--workspace,--no-worktree,--isolate-in,--worktree-only,--tierand a second--repo(CONFLICTING_FLAGS). A Windows machine, whichrxcannot 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.workspaceis{"backend":"layerfs"}); see LayerFS tasks. Refused before any task is written withLAYERFS_UNAVAILABLE(not Linux, orexperimental.layerfsunset or incomplete) orLAYERFS_NEEDS_ROOT(sudo -ncannot run).--fork,--count,--agents,--no-worktree,--isolate-inand a second--repoconflict with it (CONFLICTING_FLAGS). Inside a session it still nests under the caller (task.createwithparentTaskId), never a worktree cut from the parent's tip.--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.--repoalso 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 ofrepo-match. With no checkout it isNO_LOCAL_CHECKOUT, unless--cloneis passed: then the repo is cloned into~/.rove/repos/<host>/<owner>/<name>(reused if already there) and saved as a repo (CLONE_FAILEDif git refuses). With--machine, the id resolves to that machine's checkout, and--allow-forkaccepts a fork there (above).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.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?};nameis 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-2suffix there only (extraRepos[].branchrecords the real name); a branch named with--branchis reused as-is.--base-branchstays the primary's base;--base-for REPO=REF(repeatable;REPOis a name fromextraRepos[].nameor a path) sets another repo's.--count/--agentsgive 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.deletecovers every repo: one dirty gate over all worktrees (the refusal names each dirty repo), salvage refs written in the repo they came from,--delete-branchapplied to each repo's branch.landandsyncact 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 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).--require-reportsetsTask.requireReport: the task's own first--promptand every latersendto it arm a delivery, as if each carried--expect-report. For a task ticked unattended; change the setting withupdate --require-report on|off.add --expect-reportarms the first prompt only. The reply carriesdeliveryId.--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.
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-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, and a refused send's retrynextCommandArgscarry--plainso replaying them does not stack a second 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 newstarts 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, asadd --no-worktree) that receives the prompt, titled from it, and the reply addschildTaskId(session,deliveredand the other delivery facts describe the child;taskIdstays the target), 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 runs that new child on that engine without changing the target's own. Using it with an existing tab is aBAD_FLAGerror 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--promptis 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, orengineAlive: false= only the fallback shell is left): same task, same tab id, same worktree, a fresh conversation on the brief; the reply addsreplacedTab. A running engine is refused withTAB_ALIVEand 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 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. A tab showing an approval or question menu (its row reads?) refuses withAWAITING_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,sendgoes 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--queuepastes 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;--queuewaits 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-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).--expect-reportarms a delivery. The reply carriesdeliveryId(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 withreport. A turn that ends without one marks the deliverymissingand raises areport_missingInbox row (seedeliveries). A task withrequireReportarms everysendwithout the flag. A send whose paste fails drops its delivery, so a refused send leaves nothing awaiting.--if-runnablesends only whenshould-runsays 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 tabbusyuntil 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 exit3; when the send would have armed a delivery it carriesdeliveryIdof askippeddelivery (deliverieslists it). Repeats of the same refusal fold into one row (repeats). A skip is dropped, never held for later.dispatchandadd --countdo not take the flag:dispatchis 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 newis judged on task-level state only (gates, unreported deliveries).--queuewaits 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 fromrunningtoturn_completeoridle. A turn end that comes straight from a permission menu hands nothing over (a real approval reportsrunningbefore 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 plainsendrefuses on (status.listneeds-input, apermission_neededreading) and for a new running turn; either keeps the prompt held, withlastError(awaiting-permission,busy), until the next working turn ends. A report asked for with--expect-report(or arequireReporttask) 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 withlastError; a dead engine reports no more turns, so take it back withqueue-cancel(or fix its text withqueue-edit). A tab whose engine reports no turn events never hands over. Held prompts live in the daemon's memory:rove daemon restartdrops them.--queuedoes not combine with--tab new,--if-runnableor--respawn, and gate answers owed to the tab stay owed until a prompt is actually pasted.engine.maxLiveSessionscaps new sessions. When that setting is a positive number, a call that would START an engine session (addwith a prompt,send --tab new, asendto a task with no live session) first counts the live engine tabs across every task. At or past the cap it refuses withENGINE_LIMIT, carryingtaskId,live,limit, and thesendthat retries the same prompt. Nothing is spawned and no tab is left behind. Anaddrefused 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--taband--command. A host predating atomic admission refuses capped launches withENGINE_ADMISSION_UNAVAILABLEuntil it is restarted; inventory contention can returnENGINE_ADMISSION_BUSYfor retry.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;
--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;
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. -
queue-list [--task-id ID]: promptssend --queueis 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 }withreasonnot-found(already pasted or cancelled) orpasting(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 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.interrupt --group GROUPIDstops a whole fan-out round,interrupt --allevery task on this machine. Only tabs whose own activity isrunningare written to. Idle tabs and tabs with unknown activity are left alone, even if another tab makes the task's activityrunning. Aliased PTYs belong to their alias owner. Returns{ groupId?, count, interrupted, failures, results }:countcounts members, whileinterruptedandfailurescount tabs. Each attempted tab gets a result withtaskId,tabId, andstatus: "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.--tabgoes 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 reportsrate_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 isdeclinedwith the should-run.reason. Returns{ count, resumed, failures, results }; each result hastaskIdandstatus:resumed,not-delivered(no live engine to type into),declinedorfailed(.error).--task-idon a task that is not waiting fails withNOT_RATE_LIMITED;--allwith none waiting returnscount: 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). 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 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 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. -
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).--optionrepeats, up to 6;--question≤ 500 chars,--context≤ 4000.--tabdefaults to the tab you run in. The gate shows in the owner's Inbox asdecision_neededand ingates; it stays until answered or withdrawn. Returns{ gate }with theg-Nid. -
gate-answer --gate g-N [--option KEY] [--text TEXT]: answer an open gate (at least one;--optionmust be one of the gate's keys, elseBAD_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 keepshandoff: "undelivered": nothing is queued or retried; the nextsendor routine firing into that tab carries the answer in front of its prompt. A prompt typed into the TUI, a TUI revive, anddispatchdo not. Answering a closed gate isGATE_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 isGATE_NOT_OPEN. Deleting a task closes its open and undelivered gates. -
gates [--repo PATH] [--task-id ID] [--status open|answered|withdrawn|all]: list gates (defaultopen).contextalso lists the repo's open gates undergates. -
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, andreached.reached: falsemeans no front-end subscribes to thetab.openchannel, so nobody performed the split and an agent must not report "pane opened". (The calling CLI is itself one connection, soclients: 1alone does not prove a TUI is listening;reacheddoes.) 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'sreachedis 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: 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 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 returnsTAB_NOT_FOUNDwith aget-taskrecovery 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.shellopens a shared child task of--task-id(same directory, titledshell, so the rail and the TUI tree list it) whose owntab-1runs the login shell ($SHELL) in the task's worktree and types theROVE_TASK_ID/ROVE_TAB_IDexport into it. A fresh Ungrouped (scratch) task with no tabs is the exception: its shell is its owntab-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 theROVE_*export; the tab stays a normal shell, so Ctrl+C stops what it started.enginestarts a new conversation. A task with no conversation yet (no tabs) starts it in its owntab-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, asadd --no-worktree) whose engine, the task's own or--command's assend --tab new --commandpins it, starts idle in the child's owntab-1, ready for a first message. A task holds one engine conversation, so this never adds a second engine tab to it.--latestinstead 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 respectsengine.maxLiveSessions(ENGINE_LIMIT; the refused child is removed). The daemon's tab store mints the tab id and adds the row (onetabs.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 throughtabs.changed. A shell returns{ok, taskId, childTaskId, tabId, session, kind}withtabIdtab-1of 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}withtabIdtab-1of the task; an engine on a task that holds one returns{ok, taskId, childTaskId, tabId, session, kind}withtabIdtab-1of the child and noclients. The tab then lists inget-task .tabs[]. A session already running under the allocated id fails asTAB_COLLISION.--commandor--latestwith--kind shellisBAD_FLAG.tab-newalso 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 areBAD_FLAG, a missing fileFILE_NOT_FOUND) in the editor from Settings → General → Editor, else$VISUAL/$EDITOR, else the first installed of nvim/vim/emacs/nano (NO_EDITORwhen 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 Nopens at line N:+Nfor vi/vim/nvim/nano/emacs/emacsclient/micro/kak/mg/joe,PATH:Nfor helix, or wherever{line}sits ineditor.customCommand; the result then carriesatLine—falsewhen the editor has none of those forms and the file opened at its top.--linewith any other kind isBAD_FLAG.--kind pane --pane PLUGIN.PANE [--tab TAB]opens a pane of an enabled plugin (PANE_NOT_FOUNDotherwise), split into--tab(default the active tab) or as its own command tab, as the pane's manifestplacementsays and as the TUI's picker does; the result then also carriesleafwhen it split.--kind viewport --view-task IDadds an engine tab that shows another task's first engine session (.tabs[].viewOfnames 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.opensplits--leaf(default: the tree's active pane) toward--direction(defaultright) into a new login shell in the task's worktree;--cols/--rowsare that pane's size, for the minimum-pane gate (too small →changed: false, nothing spawned).closeends--leaf(the last pane isLAST_LEAF: close the tab instead),renamesets its label (an empty--titleclears it),resetkills a shell pane and spawns a fresh login shell (an engine pane isRESET_ENGINE).leaf-1is the tab's own process; every other pane is its own hosted session<taskId>::<tabId>::<leaf>, swept bytab-close. The layout is the tab's split tree in the daemon's tab store (onepatch), 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};splitis{activeLeafId, root}(null once back to one pane) andget-task .tabs[].splitcarries 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-exitedis set and a process walk finds no engine in the session (get-task .tabs[].engineAlivefalse): 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, thesessionIdit reopened) or, with none recorded, starts a fresh one with no first message and records the id the engine pinned; it respectsengine.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 isTAB_NOT_FOUND; a split leaf (tab-1::leaf-2) isBAD_FLAG(tab-split --action resetreopens it); a preview or viewport tab isTAB_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/httpsonly) loads a page;--path(files and diff views) is a file relative to the task's worktree, with no... Broadcast over the daemon'spanel.showchannel 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, aspane-opendoes. Returns{ shown, remembered, viewers, url? }:shownis the web pages on the task with a panel (the ones that open it now),rememberedevery web page attached and viewing a task (they keep the view for later),viewersthe task'sviewersrows. For--url,urlis{ reachable, frameable, reason? }.reachable: falseis a localhost URL while a viewing page is remote (behind a tunnel, so itslocalhostis not yours), or a host the probe could not reach.frameable: falsemeans the page cannot sit in the panel: it sendsX-Frame-Options: DENY/SAMEORIGINor a CSPframe-ancestorsthat does not allow*, or plainhttpoutside localhost, or the probe refused it. Tell the human the link instead. The probe is oneHEADover 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.localhostURLs are not probed. -
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 }.reasoniscancelled,timeout, orno-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 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). -
report --delivery ID --outcome O --summary TEXT [--evidence X]… [--next TEXT](or--json PATH|-): close the report that a delivered prompt asked for.IDis thed-<n>in the prompt's[ROVE]or[ROVE ROUTINE]line.Oisdone,partial,blockedorfailed.--evidencerepeats: a commit, PR url, test command with its result, or a file path (up to 50 items, 2,000 characters each).--nextsays what the next tick should do (4,000 characters).--summaryis capped at 32,000 characters (RESPONSE_TOO_LARGE, never truncated).--jsonreads{ 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 isDELIVERY_NOT_FOUND. Run it as the last step of the turn.--outcome doneon asenddelivery gets the same check as asucceeded:send: from a verified managed task whose branch has 0 commits it is refused withEMPTY_SUCCESS_REPORTand nothing is recorded. Pass--allow-emptywhen the task genuinely produced no commits. Routine and gate-answer deliveries, and the outcomespartial,blockedandfailed, are never refused.Reporting again replaces the earlier report. A report on a
missingdelivery flips it toreportedand clears itsreport_missingInbox row. The first line of--summary(up to 280 characters) also refreshes the task card's report summary, so no secondupdate --report-summaryis needed. For a prompt delivered bysend(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 separatesucceeded:message. Daemon RPCs behind this:delivery.arm,delivery.settle,delivery.report,delivery.list,task.requireReport. -
attention-dismiss --delivery ID: dismiss thereport_missingInbox row of a delivery.IDis thed-<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 latereportor this dismiss. An unknown id isDELIVERY_NOT_FOUND. Daemon RPC:attention.dismisswithdeliveryId.
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 alreadyapplied. The result is{ ok, taskId, updated, parent?, command?, protocol?, generic?, engine? }, whereupdatedlists the fields written, in order. Without--task-idit targets the caller's own task (the verified$ROVE_TASK_IDsession), so an agent can retitle the task it runs in; outside a verified session that isMISSING_TARGET.--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).--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 baresendfrom the task follows. Afterupdate --task-id A --parent B, A's baresendreaches B — the caller's own tab whenupdateruns from inside B, else B's live engine tab, elsetab-1. The stored parent is B's top-level ancestor (children never nest, as withaddfrom a child), and A's own children follow it there. Tree actions on that stored parent reach A as well: archiving it, ordelete --cascadeon it, takes A along..parentin the result is{ parentTaskId, dispatcher, moved }.nonealso drops a reply address that pointed into the old tree. A parent must live on the same machine as the task: a--task-idon another machine runs there, so a parent that is not on that machine isPARENT_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 atPATH: an Ungrouped session after it cloned orgit init-ed its repo, or a task created in the wrong project.PATHmay 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.projectis{ repo, moved, note },movedlisting every row regrouped. A worktree task keeps agitRepolink 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--parentonly): move every task of one fan-out round (thegroupIdthatadd --countreturns). 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 asPARENT_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, 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. A--commandthat changes the task's engine clears its model and effort pins; pass--model/--effortin 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, samegroupId), or clear the mark. A flag only: it archives nothing and leaves the round's other attempts alone.listandget-taskreport it askept.--require-report on|off:onmakes every latersendto the task arm a delivery, asadd --require-reportdoes;offrestores plain sends.--auto-fix-ci on|off:onlets 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,ghnot answering) spends no attempt..autoFixCionlist/get-taskis{ attempts, lastSha?, stopped? }, absent when off.offstops 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.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. -
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 (amaincoordinator's hand-made worktree) is invisible until attached. Run it once aftergh pr create, from any directory.--urltakes a GitHub PR URL (a trailing/filesor#fragmentis dropped) or a bare number, whichgh pr viewresolves in the current directory's repo. Without--task-idit targets your own task (MISSING_TARGETwhen the caller is not a verified session). Idempotent; a task keeps its 20 newest PRs. Returns{ taskId, url }.publishattaches 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 aret<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 showsdone/totalon 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.--statuskeeps any of the listed statuses;--searchkeeps issues whose title or body containsQ, ignoring case. Filters AND together.nextIdandskippedstill 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 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. 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 @mefor the ones assigned to you,--author @mefor 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 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 (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;--notifyhands 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--repoand--no-repo(neither isMISSING_FLAG, both isCONFLICTING_FLAGS).--no-repomakes a routine with no project: every run works in<home>/.rove/routines/<id>/as a directory session, and it takes no--base-branch.--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 (including--script,--script-timeout,--notify). To turn a script routine back into a prompt routine, pass--script ''and--prompt TEXTin the same update. Changing--script-timeoutrequires repeating--script CMD, even for an existing script routine.--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. Changing or clearing vendor/model/effort while a routine retains a standing task lets go of that task (returned asreleasedSessionTaskId, 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 ofreport --delivery <deliveryId> --outcome done --summary <text>and writes the same delivery report, sodeliveriesshows 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-sessionroutine 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, arevivedone 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 isRUN_NOT_FOUND. No response is ever inferred from a transcript. Preferreportwhen the run endedpartial,blockedorfailed, or when you have evidence to attach.routine-runs --id ID: run history, newest first. Each delivered run carriesdeliveryIdanddeliveryStatus(awaiting,reported,missing, orquiet: the turn completed with nothing to report), andresponse: { text, at }once the agent reported; both are read from the linked delivery. Runs from before deliveries existed keep the response they stored.reviveddescribes a respawned standing session;skipped_cancelledmeans disabled, changed or stopped before delivery;skipped_blockedmeans should-run refused the tick (blockedis the verdict, seeshould-run; the cause is already in the Inbox androutine-run-nowbypasses it), exceptengine-deadandengine-error, which have no Inbox row of their own and are recordeddispatch_failedwith the sameblocked. 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. 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 (tooriginunless--remote) and open its pull request withgh, or return the one already open;landfor a task on another machine. Returns{ branch, pushed, pr: { url } | null, prError? }:prErrorsays why there is no PR (ghmissing 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-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.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? }withstatusone oflanded,skipped(no commits ahead: already landed, or untouched),conflict,refused,failed,pending— and.failedRepo. Re-runninglandresumes: repos already merged read asskipped, 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 asworktree.removed: falsewith the reason); an extra named with--repois cleaned right away. A repo whose branch Rove already landed and deleted (--repo X --delete-branch) reads asskipped, notMISSING_REF, on the next fullland.--repo NAME(a name from.task.extraRepos[].name; the primary's is its directory name) lands, or with--dry-runprobes, just that repo; an unknown name isUNKNOWN_REPO.--dry-runreturns 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 astatusofready,skippedorrefused. -
sync --task-id ID [--repo NAME]: merge the base branch INTO the task's worktree (the inverse ofland; the↓Ndrift 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_CONFLICTlists 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-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.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;--cascadedeletes the whole subtree instead, and the reply lists every removed id asdeleted. 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 defaultlist, 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 --archivedlists only archived tasks. The Inbox leaves out an archived task's rows, an open decision gate included; they are kept and show again afterunarchive. Both verbs act on the task and every descendant (child tasks, recursively) together, and return{ taskId, archived, ids }, whereidsis 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.archiveis refused withTASK_BUSYwhile an engine in the subtree isrunningorpermission_needed;--forcearchives anyway and still stops nothing. A project'smaintask 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, 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. -
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? }] }; eachrefisrefs/rove/checkpoints/<workspaceTaskId>/<n>in the owning repo, sogit diff <ref>andgit restore --source=<ref>work without Rove. Managed tasks only: a project's main checkout and a directory task are your own checkout, so they getNOT_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.workspaceTaskIdin each reply names that task (it equalstaskIdfor 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: falsemeans the tree equals the latest checkpoint, which is returned instead.uncapturednames nested repos and submodules, whose contents a checkpoint cannot hold. -
rewind --task-id ID --to N [--force]: restore the worktree files to checkpointN. Files only: the branch, HEAD and the engine's conversation are not rewound, and the index is reset to HEAD. Files created afterNare deleted, files deleted since are restored, gitignored files are left alone. The current state is checkpointed first, so a rewind is undoable:undo.nin the reply is the number torewind --toto get back. Returns{ taskId, workspaceTaskId, target, undo, uncaptured }. Refused withTASK_BUSYwhile an engine of ANY task sharing the workspace (the owner or a child) isrunningorpermission_needed, because it would rewrite files under the agent; the error names the busy tasks, and--forcerewinds anyway.CHECKPOINT_NOT_FOUNDfor anNthat was never taken or has been pruned. A layerfs task rewinds its whole tree,.gitincluded, and refuses withWORKSPACE_BUSYwhile 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: agit bundleof 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_UNSUPPORTEDfor 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 onesremove-worktreewould 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-idreturns that one row, orNO_WORKTREEfor a task that owns none.ignoredBytesis the gitignored part, including dependencies, build output, user notes and local configuration. It is not a measure of disposable content.exists: nullmeans the checkout could not be reached or its presence could not be confirmed; it stays in totals as unknown. Anullsize means it could not be measured, never zero; such a row sorts last. Sizes come fromdu, 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 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 [--kind bug|idea|question](offline): file a GitHub issue on the Rove repository viagh issue create, labelledbug,enhancementorquestion(defaultbug), with an environment block (version, OS, install kind, default engine) appended to the body. Returns{ ok, filed: true, url, number }. Whenghis missing or signed out it returns{ ok, filed: false, url, hint }, whereurlis a prefilled new-issue link to open andhintsays to rungh 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(groupread): can this machine use GitHub? Returns{ git, gh, account, host, protocol, scopes, gitUsesGh, ssh, login }:gitandghare versions ornull;accountis the signed-inghlogin ornull(authDetailsays why whenghhas a login it cannot use);protocolisgh'sgit_protocol;gitUsesGhis whether git's credential helper forhttps://github.comrunsgh auth git-credential;sshis{ ok, user?, detail? }fromssh -T git@github.comwithStrictHostKeyChecking=yes, so an unknown host key is reported, never accepted;loginis the pending device flow ({ state: "pending" | "syncing" | "failed", code?, url?, error? }) ornull. A missing tool is a value, never an error. Every probe is bounded.github-login [--cancel](groupedit, inbound classcredentials): start thegh auth login --webdevice flow on this machine. Returns{ state: "pending", code, url }; type the code at the URL and pollgithub-status(.login, then.account). One flow per machine: a second call returns the pending one.--cancelstops it ({ cancelled }). The flow is a child of the daemon and ends with it. When it completes,gh auth setup-gitruns and, if the flow was started on this machine, every registered machine that is online, hasghand is signed out receives the same account. A machine already signed in as someone else is left alone.github-logout(groupedit, classcredentials):gh auth logoutfor the active github.com account on this machine. It signsghout for every tool, not only Rove. Returns the newgithub-status.github-sync --to ALIAS(groupedit, classcredentials): give a registered machine this machine's account. The daemon reads its ownghtoken in memory and sends it toALIASas a parameter ofgithub-apply; nothing prints or stores it. Returns that machine's newgithub-status. Named--tobecause--machinewould run the verb there. Refused (GITHUB_SYNC_FORWARDED) when another machine's hub sends it.github-apply --token TOKEN(internal; classcredentials): whatgithub-syncsends. Refused unless forwarded from another machine. The token goes togh auth login --with-tokenon stdin, never argv; the machine'sgit_protocolis kept (unset reads as https), thengh auth setup-gitruns. Not for direct use.