Writing Rove plugins
The developer-facing reference: everything a plugin can declare, every event Rove fires, every environment variable it injects, and every way to call back in. Design rationale lives in design/plugins.md and design/plugin-events.md; this page is the contract.
A plugin is a directory with a rove-plugin.toml manifest plus any argv
commands your machine can run: Bash, Node, Bun, Python, Rust, a prebuilt
binary. No SDK is required: the whole rove CLI and the daemon socket are the
plugin API, with an optional TypeScript SDK described below. Rove owns the host
surface (install, validation, event dispatch, env injection, panes, settings
UI, run logs); you own the implementation.
Quickstart
mkdir my-plugin && cd my-plugin
cat > rove-plugin.toml <<'EOF'
id = "you.hello"
name = "Hello"
version = "0.1.0"
min_rove_version = "0.8.24"
[[events]]
on = "agent.turn-complete"
command = ["sh", "-c", "echo \"$ROVE_PLUGIN_TASK_TITLE finished a turn\" >> \"$ROVE_PLUGIN_STATE_DIR/log\""]
EOF
rove plugin link . # register your working directory (dev loop)
rove plugin log you.hello # inspect hook runs (exit codes, output, timing)link is a one-time registration. After it, a running daemon picks up edits to
your rove-plugin.toml within about half a second — add an [[events]] hook,
fire the event, and rove plugin log shows the run. Re-run link only when
you move the plugin, or when its id or version changes.
That half-second applies to the parts the DAEMON runs: [[events]],
[[startup]], [[shutdown]], [[actions]], [[panes]], [[settings]]. An
[[engines]] table is read once per Rove process instead, so a running TUI
keeps the engine list it booted with — see the [[engines]] note under
Manifest reference.
Optional SDK (TypeScript)
The contract above is the API: any language, no SDK required. For
TypeScript/JavaScript authors, @sma1lboy/rove-plugin-sdk wraps that
same contract with types and autocomplete (zero deps, Node ≥ 18 or Bun):
import { pluginContext, pluginEvent, notify, Pane, RoveSocket } from "@sma1lboy/rove-plugin-sdk"
const ctx = pluginContext() // typed ROVE_PLUGIN_* env
const ev = pluginEvent() // typed event envelope (null outside [[events]])pluginContext()/pluginEvent(): the env contract, typed.readSettings()/setting(): your[[settings]]values from config.env.rove()/roveJson()+notify/dispatch/listTasks/openPane/promptUser:$ROVE_BIN_PATHcallbacks.promptUserpops the TUI's own input dialog and returnsnullfor every non-submit path.RoveSocket: daemon socket client:request(name, payload)+ live channelsubscribe(alwaysrole: "pane").Pane+parseKeys: a tiny pane kit for[[panes]]pages: alt screen, raw-mode keys, resize, absolute-addresseddraw(lines).parseKeysturns one raw stdin chunk into key events, for a pane driving its own read loop.PLUGIN_EVENT_NAMES/DAEMON_CHANNELS: the catalogs as typed unions. These are the SINGLE source: the daemon itself imports them from the SDK's./contractmodule, so host and SDK can't drift by construction.
Package README has full examples: packages/rove-plugin-sdk/README.md.
Module-by-module SDK reference: PLUGIN-SDK.md.
SDK examples
Five runnable examples live under packages/rove-plugin-sdk/examples/, one
per surface. Each clip below is the real TUI — recorded through the same
browser-PTY path the README assets use, against a throwaway home with the
example already linked (packages/rove-harness/e2e/films/), so what
you see is where your plugin actually shows up.
[[panes]] — the pane is offered in the ctrl+e picker under its declared
title, splits in beside the engine, and redraws when a task is created from
outside the TUI: it is subscribed to task.snapshot, not polling.
[[engines]] — a manifest-only plugin puts fake-coder in the engine list
next to the built-ins, with the identity and screen-state rules it declared.
[[settings]] + [[actions]] — Settings → Plugins renders the settings the
manifest declares; editing one writes the config .env your plugin reads on
its next run.
[[events]] — an issue.changed fired from outside the TUI reaches the hook,
and the plugin's run summary records the exit status and timing.
[[events]] + notify() — the hook calls back INTO the host, and its own
copy appears as a toast in every attached UI.
Re-record with:
cd packages/rove-harness
bun e2e/hero-fixture.ts --fresh # throwaway home + a real repo
bun e2e/hero-plugins.ts # link every example (BEFORE the TUI boots)
bun e2e/hero-serve.ts # warm capture stack (keep running)
bun e2e/film.ts take task-board # also contrib-engine, settings-demo, hello-events, turn-notify
bun e2e/film.ts render task-board # re-encode from the committed cast; no stack neededLinking has to happen before the harness starts: the TUI reads the plugin registry once at boot, so a plugin linked mid-session contributes nothing a running TUI can see. A take resets the state the previous one left (split panes, run logs, the settings values) and removes the task or story it filed, but a fresh fixture is still the safest start.
Publish: push a public GitHub repo (one plugin per subdirectory is fine),
add the topic rove-plugin → it appears automatically in the marketplace
(rove.run/plugins, rove plugin search, and
Settings → Marketplace inside the TUI) and in the plugin
directory. Users install with rove plugin install owner/repo[/subdir]
or from that Settings section, and stay fresh with
rove plugin outdated / rove plugin update --all (an update is a clean
reinstall of the managed checkout; config/state survive).
Manifest reference
id = "you.example" # letters/digits/dot/colon/underscore/hyphen
name = "Example"
version = "0.1.0"
min_rove_version = "0.8.24" # install refuses older Rove versions
description = "…" # optional
platforms = ["macos", "linux", "windows"] # optional; item-level override
[[build]] # runs at GitHub install (after preview confirm), cwd = checkout
command = ["npm", "install"] # self-provision deps INTO the plugin dir; `link` skips build
[[startup]] # once per daemon start; the socket may not accept connections yet — retry your connect. One-shot, not a daemon
command = ["node", "restore.js"]
timeout_ms = 30000 # optional, 100…600000; the host SIGKILLs the
# hook's process group at the deadline.
# Default 30s for [[startup]]/[[events]], 3s
# for [[shutdown]] (it delays daemon stop)
[[shutdown]] # at daemon stop; bounded (~3s), the host kills a hook that lingers
command = ["node", "flush.js"]
[[actions]] # on-demand: rove plugin action invoke you.example.greet [args…]
id = "greet" # local id, no dots; extra CLI args append to argv
title = "Say hello"
command = ["sh", "greet.sh"]
[[events]] # async observer fired by the daemon (catalog below)
on = "agent.turn-complete"
command = ["sh", "notify.sh"]
[[panes]] # a terminal surface in the task workspace
id = "board"
title = "Board"
placement = "split" # split (default: joins the focused chattab's
# split group beside the engine) | tab (own tab)
command = ["node", "$ROVE_PLUGIN_ROOT/board.js"] # cwd = the TASK WORKTREE
[[settings]] # rendered as an editor in Settings → Plugins
key = "YOU_EXAMPLE_MODE" # stored as KEY=value in your config .env;
# must be a plain env var name, and may not
# be one that steers how a process runs
# (PATH, LD_PRELOAD, NODE_OPTIONS, …)
label = "Mode"
type = "enum" # string | number | boolean | enum | secret
options = ["fast", "fancy"]
default = "fast" # what the Settings editor pre-fills, NOT a
# stored value: nothing reaches the config
# .env until the user saves, so read it as
# `setting(dir, key, "fast")`. TOML `true` /
# `false` / numbers are accepted and become
# "1" / no default / their decimal spelling
[[settings]] # `secret` masks the value everywhere it is
key = "YOU_EXAMPLE_TOKEN" # shown, for keys the user pastes in
label = "API token"
type = "secret"
[[file_handlers]] # claim Files-pane opens by filename pattern
pattern = "\\.(png|jpg)$" # JS regex, case-insensitive, vs the file name
action = "greet" # your action, invoked with the absolute path
[[engines]] # contribute a coding-CLI engine
id = "aider" # VendorId; may not shadow a built-in (claude/codex/copilot/kimi/pi/omp/bob) or shipped engine (gemini/opencode/cursor/grok/droid/amp/devin/qodercli/cline/kiro/maki/antigravity)
name = "Aider" # display name in the selector and Settings
command = ["aider"] # launch argv; argv[0] is the binary
# process_names = ["aider-core"] # extra ps basenames (post-launch renames)
# first_message_delivery = "paste" # argv (default) | paste — see below
[engines.identity] # optional product identity for UI labels
short_name = "Aider" # falls back to `name`
[[engines.rules]] # screen-state rules, first match wins;
state = "blocked" # declare blocked before working
all = ["(y)es/(n)o"] # every string must appear (case-insensitive)
[[engines.rules]]
state = "working" # working | blocked | idle
any = ["ctrl-c to interrupt"] # at least one must appear
# line_regex = ["^\\s*⠋"] # or: one screen line matches a regex
# bottom_lines = 12 # trailing non-empty lines examined (default 12)An [[engines]] edit is not picked up by a running TUI. The engine table
is process-level state, built once at Rove start from the enabled plugins'
manifests, so rove plugin install / link / enable from another terminal
registers the engine on disk without any running TUI seeing it. Restart the
TUI, or toggle the plugin off and on again in Settings → Plugins, which
reloads the table in place. The same applies to the same-session dev loop:
edit the [[engines]] table, then restart.
command is argv, never a shell, for events, startup, shutdown and
actions: no expansion, no pipes, no globs. Panes are the exception — a
pane runs through the user's interactive login shell (sh -ilc, the same
launch path as an engine tab), expands $ROVE_PLUGIN_ROOT, and therefore
inherits the rc-file environment: your command[0] must resolve on the PATH
the user's .zshrc/.bashrc builds, not the daemon's, and anything those
files print (version-manager chatter, MOTDs, banners) reaches the terminal
before your first draw. The pane kit's start() handles that for you — it
enters the alternate screen and clears it, so rc output stays on the primary
screen; a pane that does not use the kit should clear the screen itself
before its first frame. Unknown event names are warnings (forward compat);
invalid types/patterns are install-time errors.
first_message_delivery says how the CLI takes a session's first message.
The default "argv" appends the prompt as a positional argument. Declare
"paste" when argv[1] means something else — a subcommand, or a project
directory — or the launch dies on the prompt text instead of running it
(opencode "Run ls -la" exits with Failed to change directory to …).
With "paste" the message is typed into the running pane instead.
The accepted platform tokens are exactly macos, linux, and windows.
A top-level list applies to the whole plugin; platforms on an individual
build, startup, shutdown, action, event, or pane replaces that list for that item. With
no declaration, Rove assumes the command is portable and allows it everywhere.
A plugin whose top-level platforms excludes the current machine stays in
the registry but never runs; Settings → Plugins marks that row not supported on this platform rather than showing it as healthy.
Event catalog
Declare [[events]] hooks; each fire runs your command with the envelope in
ROVE_PLUGIN_EVENT_JSON. Events are asynchronous observers. Your exit
code and output never block or change what happened. Support: C = Claude
Code, X = Codex, K = Kimi Code.
This table is the one-line index. Per-event trigger semantics, exact
detail fields, and envelope samples live in
PLUGIN-EVENTS.md.
| Event | Fires when | Detail highlights |
|---|---|---|
task.created / task.deleted | task appears/disappears in the index | task context |
task.changed | any watched task field changed (title/branch/status/pin/vendor/…), fired off the snapshot diff, so EVERY mutation path counts | fields, from, to |
task.landed | a task's branch merged back into its base repo | strategy, landedOn, commit |
task.pr-changed | the task's PR status changed (open/merged/closed, checks) | from, to (TaskPRStatus) |
worktree.created | a task's worktree materialized: lazy ensure, adopt, or scratch-adopt | task context |
issue.changed | a daemon-tracker issue mutated (create/edit/status) | repo, op |
note.filed | a session filed a field note (rove api note) | repo, author, text, routed, persisted |
message.delivered | text was dispatched into a task's live session (dispatch/note relay) | source, tabId, length |
attention.handled | the human resolved an inbox episode | how: dismissed|read, tabId |
automation.dispatched / automation.skipped / automation.failed | one scheduled-automation run finished with that outcome | automationId, name, repo, status, trigger, scheduledFor, tabId, error |
quota.exhausted / quota.resumed | rate-limit auto-resume armed / delivered its continue prompt | vendor, resumeAt / delivered |
session.exited | a hosted PTY child died abnormally (the crash signal; the engine's own session.end hook never fires on a crash) | tabId, pid, code, signal, exitedAt, tail |
plugin.enabled / plugin.disabled | YOUR plugin was enabled/disabled in the registry (delivered only to the affected plugin). Registry membership only — a manifest that stops parsing does not fire teardown | pluginId |
task.opened / project.opened | the user selects/enters a task / project row | |
file.will-open / file.opened / file.closed | Files-pane open, before/after; editor tab closed | path, via: plugin|editor|external |
tab.opened / tab.closed | a workspace tab appeared/went away (restores don't fire) | tabId, kind, title, vendor, purpose |
agent.running / agent.idle / agent.turn-complete / agent.permission-needed / agent.rate-limited / agent.error | activity-STATE transitions, deduped per task+tab | tabId when the source state identifies a tab |
session.start / session.end | engine session lifecycle (C, K; X start only) | |
turn.prompt / turn.complete / turn.failed / turn.interrupted | one event per turn edge (C, X, K; failed: C, K; interrupted is a native hook on K only — on C and X the attached TUI emulates it, so it never fires with no TUI attached) | failure class on failed; turn (id/model/usage/startedAt/endedAt) on complete when the transcript yielded one |
tool.pre / tool.post / tool.failed | every tool call (C, X, K; failed: C, K); installed into engine config only while some enabled plugin subscribes | tool.name, tool.id |
attention.permission / attention.question | the engine blocked on a human (permission: C, K; question: C) | waiting |
context.pre-compact / context.post-compact | context compaction (C, X, K) | compact.trigger: manual|auto |
subagent.start / subagent.stop | nested agent lifecycle (C, K) | subagent.type/id |
Envelope (ROVE_PLUGIN_EVENT_JSON):
{
"event": "tool.post",
"taskId": "…", // when the event mapped to a task
"task": { "id", "title", "repo", "branch", "worktreePath", "vendor", "status" },
"vendor": "claude", // agent-layer events
"tabId": "…", "sessionId": "…",// when known
"detail": { /* per-event, see table */ },
"at": 1690000000000
}The principle: any observable product moment is a candidate event. The
catalog grows as subsystems expose their edges. Threshold policies stay OUT:
"worktree dirtier than N" is a plugin's own judgment. Subscribe to the
worktree.changes channel over the raw socket and decide yourself. If your
plugin needs a moment that isn't fired yet, ask via rove feedback or a
GitHub issue; the plumbing (ui.reportEvent → plugin sink) makes additions
cheap.
Environment contract
Every plugin command gets, on top of the user's environment:
| Variable | Meaning |
|---|---|
ROVE_BIN_PATH | exec this to call back into Rove — the absolute path of the running install when that is a runnable file (an npm install, a compiled binary), otherwise the bare rove name resolved on PATH, which is what a dev checkout run through bun falls back to. In that fallback your callbacks run a different build than the daemon that launched you, so a verb or flag the daemon has can still fail as a usage error: compare $ROVE_BIN_PATH --version against the daemon's own roveVersion (see Which host am I talking to) before blaming your own arguments |
ROVE_SOCKET_PATH | daemon unix socket, for raw JSON requests |
ROVE_HOME_DIR | set when Rove runs against a non-default home (keep passing it through) |
ROVE_PLUGIN_ID, ROVE_PLUGIN_ROOT | who you are, where your files are |
ROVE_PLUGIN_CONFIG_DIR | user-editable config (.env etc.); survives reinstall |
ROVE_PLUGIN_STATE_DIR | your durable state; survives reinstall |
| events | ROVE_PLUGIN_EVENT, ROVE_PLUGIN_EVENT_JSON, ROVE_PLUGIN_TASK_ID, ROVE_PLUGIN_TASK_TITLE |
| startup | ROVE_PLUGIN_EVENT=startup |
| shutdown | ROVE_PLUGIN_EVENT=shutdown |
| actions | ROVE_PLUGIN_ACTION_ID, ROVE_PLUGIN_INVOKE_CWD (where the user invoked, usually "the repo I mean") |
| panes | ROVE_PLUGIN_ENTRYPOINT_ID, ROVE_PLUGIN_TASK_ID; cwd is the task worktree. Panes get no _TASK_TITLE — read it with "$ROVE_BIN_PATH" api get-task --task-id "$ROVE_PLUGIN_TASK_ID" |
Only the ROVE_* namespace, rove-plugin.toml, min_rove_version, and
@sma1lboy/rove-plugin-sdk are supported. Update plugins using retired aliases.
Never write durable state under ROVE_PLUGIN_ROOT. GitHub installs are
managed checkouts replaced on reinstall. Settings you declare in
[[settings]] arrive as plain vars in your config .env; source it
(. "$ROVE_PLUGIN_CONFIG_DIR/.env") or read it yourself.
Because that file is sourced, a settings key must be a plain env var name
(^[A-Za-z_][A-Za-z0-9_]*$), and a small set of names is refused outright:
those that change how a process runs rather than what it reads — PATH,
HOME, SHELL, the LD_*/DYLD_* loader vars, NODE_OPTIONS and its
per-language siblings, BASH_ENV, GIT_SSH_COMMAND, EDITOR/PAGER.
A rejected key fails the whole manifest at parse time, so fix it before
publishing. Asking for an API key is fine and expected — use type = "secret" so the value is masked in Settings. Your config .env, state
directory, and log.jsonl are all owner-only (0600/0700).
Calling back into Rove
CLI (recommended, portable): exec $ROVE_BIN_PATH with any command.
The high-value verbs live under rove api: machine-readable list via
rove api schema, human list via rove api help. Highlights:
"$ROVE_BIN_PATH" api add --repo <dir> --title T --prompt "…" # create task + start engine
"$ROVE_BIN_PATH" api dispatch --task-id ID --prompt "…" # text into a live session
"$ROVE_BIN_PATH" api list # all tasks (JSON)
"$ROVE_BIN_PATH" api notify --title "done" # toast in every attached UI
"$ROVE_BIN_PATH" api issue-create --repo <dir> --title "…" # daemon issue tracker
"$ROVE_BIN_PATH" api prompt --title "URL?" # host input dialog → {value}|{cancelled}
"$ROVE_BIN_PATH" api read-output --task-id ID # structured session reads
"$ROVE_BIN_PATH" plugin pane open you.example.board # qualified-id form
"$ROVE_BIN_PATH" plugin pane open --plugin you.example \
--entrypoint board # equivalent flag form
"$ROVE_BIN_PATH" plugin pane open you.example.board --task ID # a specific task, not the active oneplugin pane open prints JSON: {"ok":true,"clients":N,"pane":…,"taskId":…,"title":…}.
Branch on clients, not the exit code — the open is a broadcast, and 0
means no attached UI performed the split. Without --task the host uses the
active task and fails when there is none, so an event hook should pass its
own $ROVE_PLUGIN_TASK_ID.
Socket (advanced): newline-delimited JSON frames on ROVE_SOCKET_PATH
({"type":"request","id":"1","name":"task.list","payload":{}}); request
names and payloads in packages/rove-daemon/src/daemon/protocol.ts. Prefer
the CLI unless you need push channels.
Which host am I talking to
The hello request answers it, and it is the only thing that can: your SDK
version describes what YOU were built against, not what the running daemon
knows. Send {"type":"request","id":"1","name":"hello","payload":{}} (the
SDK wraps it as RoveSocket.hello()) and read back:
roveVersion— the daemon's build version. The SDK also surfaces it asroveVersion; the wire field keeps its original spelling.capabilities— the broadcast channels this daemon has. A channel name it does not know is dropped from asubscribefilter silently, so this is how you tell "the host is too old for that channel" from "nothing has happened yet".protocolVersion/minProtocolVersion— the wire range it accepts.homeDir— its state root. A different home means you reached a foreign daemon (a sandbox one on the production socket path).
Interaction surfaces
- ctrl+e picker: every enabled plugin's panes are listed by title; picking one opens it with your declared placement.
- User keybindings: users bind chords themselves in
~/.rove/settings/keybindings.yaml:plugins: { ctrl+b: pane:you.example.board, f6: action:you.example.greet }. Ship the suggestion in your README; Rove ships no default plugin chords. - Files pane:
[[file_handlers]]claims opens by pattern. - Engines:
[[engines]]contributes a coding CLI to the engine selector: identity, launch command, and screen-state rules for working / needs-input badges. For tabs attached to an open TUI, ablockedscreen rule also updates the sidebar and attention inbox within one screen poll (up to six seconds). Hook claims take precedence. The screen claim clears when the dialog disappears or polling detaches; unopened tabs need hook reports. Screen reports do not emit plugin lifecycle events. Beyond screen scraping, a wrapper can report PRECISE activity itself:rove api engine-report --kind turn-complete --engine <id>drives the same badge / attention-inbox / plugin-event pipeline the built-in hook adapters use (kinds:session-start|turn-start|turn-complete|turn-failed|turn-interrupted|awaiting-input|session-end, plus the plugin-onlytool-*/*-compact/subagent-*family). Account detection, history readers, and model catalogs still require a built-in adapter in Rove itself; render those surfaces yourself via[[panes]]. - Task-row tokens: put one short label on a task's sidebar/board row — see below.
- Host input dialog:
rove api prompt --title "…"(SDK:promptUser()) pops the TUI's standard input dialog and blocks for the answer:{value}on submit,{cancelled, reason}on esc/timeout. Use it instead of hand-rolling in-pane prompts. - Settings → Plugins: enable/disable, declared surfaces, last run,
and your
[[settings]]editors. - Settings → Marketplace: your repo's
rove-plugintopic listing, its description, and the install preview built from your manifest's commands. - CLI:
rove plugin action invoke,rove plugin pane open,rove plugin log,rove plugin config-dir(prints the plugin's config directory).
Task-row tokens
The row is where the human already looks, and until now nothing a plugin knew could reach it. That is the single reason a coordination plugin could not be built on Rove: the point of one is to say, on the row, who claimed this, what queue it is in, what your own system thinks of it.
"$ROVE_BIN_PATH" api row-token --task-id ID --text "@ana" --tone info --ttl 600
"$ROVE_BIN_PATH" api row-token --task-id ID --clearimport { setRowToken, clearRowToken } from "@sma1lboy/rove-plugin-sdk"
await setRowToken(taskId, "@ana", { key: "claim", ttlSeconds: 600, tone: "info" })Every token expires
This is not optional and it is the point. A token carries a TTL (default
60s, max 1h); readers drop it when it lapses, and the host republishes at each
expiry so a label fades on its own. Keep a label by re-writing it — the
same --key replaces the token and renews its deadline.
So the label on screen is evidence that your plugin is alive and still believes what it says, not a record that something once wrote it. Disable your plugin, kill it, let it crash: every label it painted is gone within its TTL, with nothing to clean up. Tokens are in memory only — a daemon restart clears them, deliberately, because restoring a claim whose author is gone is exactly the stale state the TTL prevents.
clearRowToken() exists for the moment you know a label is wrong. Waiting
out the TTL is the normal removal.
Yours vs host-owned
| You own | The host owns |
|---|---|
| your label text, in your own slot | the derived task group (waiting-on-you / landing / …) |
which of your two slots per task it lands in (--key) | the activity badge, the PR chip, the spinner |
its semantic tone | what that tone actually looks like, per theme |
| when to refresh it, and when to drop it | the title, the branch, the row layout and its budget |
A plugin cannot address anything in the right-hand column. A plugin able to
overwrite "waiting on you" could make the row lie about whether a human is
blocked, and no third party should be able to do that. tone names a role
(info / success / warning / error / muted), never a colour, so the
user's active theme still decides how your label is drawn — and no vendor or
engine name belongs in one: the engine adapter owns that vocabulary
(see AGENTS.md).
Limits
- 24 characters, whitespace collapsed. A row shares ~2 lines with its title and branch; past that a token stops being a label and becomes the row.
- 2 slots per plugin per task. How many plugins are installed is the user's own decision; one plugin filling the row is not.
- TTL 1s…1h, clamped. "Forever" is not expressible.
sourceis yourROVE_PLUGIN_ID, carried for attribution and the quota above. It is not authenticated — a plugin already runs arbitrary code as the user — so treat it as a label, not a permission.- The write returns
{ ok: false, reason: "UNSUPPORTED" }on a host with no row-token surface; the SDK helpers answerfalserather than throwing, so you never have to version-gate the call.
Runnable example: examples/row-tokens/.
Ground rules
- Hooks must be fast and silent. Event hooks run on real product
moments; do your slow work detached. Exit non-zero only for real failures;
output is capped at 8 KB per run in
log.jsonl. - Every hook has a deadline. 30s for
[[startup]]and[[events]], 3s for[[shutdown]], or whatevertimeout_msyou declare. At the deadline the host SIGKILLs the hook's whole process group, so acurlwith no--max-timeon atool.posthook stops one process short of leaking one per tool call. A hook still running after ~2s gets aphase: "running"record inlog.jsonl, ahead of the record its exit will write. - Never block. Events are observers; there is no veto surface. Blocking tweaks (deny a tool call) belong in engine-native hooks the user installs directly.
- Trust model: plugins run as the user with their environment; installs preview every command and build step first — on the CLI and in Settings → Marketplace alike — but nothing is sandboxed. Keep your repo auditable. That's what gets you installed.
- Reference implementations: the first-party plugins in Sma1lboy/rove-plugins (notifications, GitHub/Linear task starters, lazygit pane, Chromium pane, the character-cell video player).