Skip to main content

voro_core/
agent.rs

1//! Agent dispatch templates (DESIGN.md §5, §8): command templates, not state,
2//! so they live outside the database. Owns the built-in `claude`/`codex`
3//! definitions, layers the user's `~/.config/voro/voro.toml` on top, and
4//! resolves which agent a task dispatches with.
5//!
6//! An agent is a set of verb templates; only `dispatch` is required (`cmd` is
7//! an accepted alias). The optional `sessions`/`attach`/`resume`/`message`/`stop`
8//! verbs unlock session-aware dispatch and `plan` unlocks the TUI's interactive
9//! planning sessions (DESIGN.md §8); each degrades gracefully when absent
10//! (docs/agent-integration.md). Config is layered: built-ins under `voro.toml`,
11//! which may add agents, override a built-in wholesale, and set `default_agent`
12//! and viewers. A missing file is not an error.
13
14use std::collections::BTreeMap;
15use std::path::{Path, PathBuf};
16use std::sync::LazyLock;
17
18use serde::Deserialize;
19
20use crate::error::{Error, Result};
21use crate::model::LivenessSource;
22use crate::scheduler::{AttentionCosts, DEFAULT_MAX_RUNNING};
23use crate::template::{render, shell_quote};
24
25/// The prompt-file substitution in the `dispatch`, `plan` and `message`
26/// templates. The working directory is handled by the spawner, not the
27/// template.
28pub const PROMPT_FILE_PLACEHOLDER: &str = "{prompt_file}";
29
30/// The task-id substitution in the `dispatch` template, the numeric id of the
31/// task. Optional — a template that omits it dispatches unchanged — so a
32/// template can put the id somewhere other than the session name. Refused on
33/// `plan`, which serves targets that have no task id to bind.
34pub const TASK_ID_PLACEHOLDER: &str = "{task_id}";
35
36/// The session-name substitution in the `dispatch` and `plan` templates: the
37/// name Voro composes for the session a launch opens ([`Launch::session_name`]),
38/// so every backgrounded session is findable by a name Voro chose. Optional,
39/// and refused on the session verbs for the same reason [`MODEL_PLACEHOLDER`]
40/// is — they act on a session that already exists and has its name.
41pub const SESSION_NAME_PLACEHOLDER: &str = "{session_name}";
42
43/// The session-reference substitution in the `attach`, `resume`, `message`,
44/// `logs` and `stop` templates: the agent-opaque reference captured at dispatch
45/// (a Claude session UUID, a Codex session id, a tmux session name).
46pub const SESSION_PLACEHOLDER: &str = "{session}";
47
48/// The fresh-reference substitution in the `message` template, bound to a v4
49/// UUID Voro generates for the send (DESIGN.md §8). An agent whose sessions are
50/// held by a supervisor cannot be resumed headlessly while that supervisor
51/// lives; it can be *forked*, which continues the same conversation under a
52/// reference the caller names up front. A `message` template carrying this
53/// placeholder is declaring that shape, and the session row follows the fork:
54/// what Voro binds here becomes the session's reference. Optional — a template
55/// without it resumes in place and keeps the reference it had.
56pub const NEW_SESSION_PLACEHOLDER: &str = "{new_session}";
57
58/// The model substitution in a verb template, resolved from the agent's own
59/// `model`/`model_deep`/`model_plan` keys (DESIGN.md §8). Voro is model-blind:
60/// the values are opaque strings it pastes into the command and never
61/// interprets. Optional — an agent with no `{model}` anywhere takes no model
62/// direction at all, and a deep task dispatches with it unchanged.
63pub const MODEL_PLACEHOLDER: &str = "{model}";
64
65/// The substitution in a viewer command template (DESIGN.md §11a): the checkout
66/// path of the task's project — or the task's worktree, when it has a branch
67/// checked out in one (DESIGN.md §8). Optional — a viewer that acts on the
68/// current directory (`git difftool -d`) needs no placeholder.
69pub const VIEWER_PATH_PLACEHOLDER: &str = "{path}";
70
71/// The substitution in a viewer command template for the task's git branch, or
72/// empty when the task has none. Paired with [`VIEWER_BASE_PLACEHOLDER`] it lets
73/// a viewer express a diff range (`{base}...{branch}`) rather than a bare
74/// directory (DESIGN.md §8).
75pub const VIEWER_BRANCH_PLACEHOLDER: &str = "{branch}";
76
77/// The substitution in a viewer command template for the checkout's default
78/// branch — the base a task branch is diffed against (DESIGN.md §8).
79pub const VIEWER_BASE_PLACEHOLDER: &str = "{base}";
80
81/// The agents Voro ships with the binary, layered under any `voro.toml`
82/// (DESIGN.md §5/§8). Compiled in, so a binary upgrade upgrades the agents; a
83/// user `voro.toml` can override either wholesale ([`Provenance::UserOverride`]).
84/// `claude` launches attachably (`--bg`) with the full session verb set and
85/// plans interactively in the foreground; `codex` covers the headless-resume
86/// shape. Must parse and pass [`validate_agent`].
87///
88/// Both claude verbs name their session from `{session_name}` rather than
89/// spelling `voro-{task_id}` themselves, so every launch Voro makes — a
90/// dispatch, a refine, a planning session — carries a distinct Voro-composed
91/// name in `claude agents` and the `/resume` picker (DESIGN.md §8). `--name` is
92/// not a background-only flag, so the foreground `plan` verb takes it too.
93///
94/// The claude verbs take their model from `{model}` rather than a baked-in
95/// flag, so the model varies per purpose and per task: `model` is the workhorse
96/// a normal dispatch runs, `model_deep` the stronger one a `deep` task earns,
97/// and `model_plan` the one interactive planning reasons with (DESIGN.md §8).
98/// They name `claude` model *aliases* (`opus`, `fable`), not pinned model ids,
99/// so each resolves to the current model of that class and does not churn with
100/// each release; an operator wanting other models overrides the agent
101/// wholesale in `voro.toml`. `codex` carries no `{model}`, which is the
102/// no-model-direction case: a deep task dispatches with it unchanged.
103///
104/// The claude `message` verb is `resume` plus `-p`, and the near-duplication is
105/// deliberate: a verb is an opaque per-agent contract, which is exactly what
106/// lets an agent define a subset of them and degrade per-verb. `codex` defines
107/// no `message` and the TUI's quick-message key says so on the status line.
108/// A `claude --bg` session keeps its supervisor process after finishing its
109/// turn, and that supervisor refuses a headless `--resume` for as long as it
110/// lives — Voro releases it at rest through `stop`, so the send lands on the
111/// session's own reference (DESIGN.md §8).
112///
113/// It carries `--permission-mode` for the same reason `dispatch` does: the mode
114/// belongs to a launch rather than to a verb (DESIGN.md §8). The flag is per
115/// invocation rather than a property of the session, so a resumed turn without
116/// it runs in the default ask mode against a stdin at `/dev/null`: every edit
117/// and every command outside the allowlist stops for an approval nobody can
118/// give, and the refusals land in the launch log rather than the TUI. A send
119/// like that appears to have been delivered and quietly does nothing, which is
120/// the one failure a fire-and-forget channel cannot report — and since `message`
121/// carries every rejection's feedback, a rework session missing the flag cannot
122/// even run `voro done`, so its finished work goes unreported and reconcile
123/// lands the task in `stalled`, reading as an agent that died.
124///
125/// `resume` deliberately carries no mode: it hands the operator a terminal and
126/// no prompt, so the ask-mode default is answerable by whoever is sitting there.
127///
128/// The claude `logs` verb replays a background session's screen, which is the
129/// only place a usage cap is legible (DESIGN.md §8): `claude agents --json`
130/// reports a capped session as plain `blocked`, the same word a permission
131/// prompt earns, and Voro's own launch log holds nothing but the backgrounding
132/// banner. Two details of the spelling are load-bearing. `claude logs` keys on
133/// the *job* id — the first eight characters of the session id — so `{session}`
134/// is truncated in the template rather than passed whole; and the output is a
135/// full screen replay of unbounded length, so it is tailed here rather than
136/// read whole by the caller. It exits zero when it finds no such job, printing
137/// a not-found line instead, which is why nothing reads its status: text with
138/// no cap signature in it means "not capped", however it came about.
139///
140/// The claude `cap` verb answers the same question `logs` does — when does the
141/// window reopen — about the account rather than about a session, and as an
142/// instant rather than as a clock time on a screen (DESIGN.md §8). The CLI's
143/// stream transport emits a `rate_limit_event` carrying `resetsAt`, a Unix
144/// epoch, so the spelling is a one-turn print with the event filtered out of
145/// the stream: `"status":"rejected"` is what makes it a report of a cap the
146/// account is *held at* rather than a note on the window it is spending, and
147/// the greps keep the epoch beside it. A live cap always carries the reset,
148/// since the same header the message renders its time from is where this comes
149/// from.
150///
151/// It asks with `{model}` — the model whose window is in question — because a
152/// Claude subscription meters more than one: the five-hour pool, the weekly
153/// one, and a separate allowance for each strong model, which the CLI names
154/// "Opus limit", "Sonnet limit" and "Fable 5 limit". A probe on the wrong model
155/// would answer for the wrong window, and answer *earlier* than the truth
156/// whenever a cheap model's pool reopens first. Asking on the session's own
157/// model also makes the case that matters free: a refused request is a 429 and
158/// bills nothing, so the only probe that costs a turn is one that finds the
159/// account healthy — and prints nothing.
160///
161/// The other load-bearing property is that it prints nothing when the account
162/// is not refused, which is the contract's whole negative answer. The `timeout`
163/// is the belt to that brace: a cap is not retried (§8), so the turn ends at
164/// once, but nothing in Voro should wait on an agent indefinitely.
165///
166/// The claude `stop` verb retires a session from the agent's own listing once
167/// Voro closes its row — and, at rest, once it hands back (DESIGN.md §8): the
168/// release the supervisor holds is what a headless `message` resumes through.
169/// A `claude --bg` session outlives its work
170/// twice over — the entry stays in `claude agents` and the supervisor holding it
171/// runs until the machine reboots — so an operator who dispatches all week reads
172/// their session list through a wall of finished ones. The conversation survives
173/// the call: `claude stop` keeps the transcript and drops only the entry from the
174/// default listing, which `claude attach` can still reopen. It keys on the *job*
175/// id as `logs` does, so `{session}` is truncated to the same eight characters,
176/// and it exits zero on a session whose supervisor is already gone, which is what
177/// lets Voro fire it without checking first.
178const BUILTIN_AGENTS: &str = "\
179[agents.claude]
180dispatch   = \"claude --bg --name \\\"{session_name}\\\" --permission-mode auto --model {model} \\\"$(cat {prompt_file})\\\"\"
181sessions   = \"claude agents --json\"
182attach     = \"claude attach {session}\"
183resume     = \"claude --resume {session}\"
184message    = \"claude -p --resume {session} --permission-mode auto \\\"$(cat {prompt_file})\\\"\"
185logs       = \"claude logs \\\"$(printf %.8s {session})\\\" 2>/dev/null | tail -c 20000\"
186cap        = '''timeout 120 claude -p --output-format stream-json --verbose --model {model} hi 2>/dev/null | grep -o '\"status\":\"rejected\"[^}]*\"resetsAt\":[0-9]*' | grep -o '[0-9][0-9]*$' | tail -1'''
187stop       = \"claude stop \\\"$(printf %.8s {session})\\\"\"
188plan       = \"claude --name \\\"{session_name}\\\" --permission-mode auto --model {model} \\\"$(cat {prompt_file})\\\"\"
189model      = \"opus\"
190model_deep = \"fable\"
191model_plan = \"fable\"
192
193[agents.codex]
194dispatch = \"codex exec \\\"$(cat {prompt_file})\\\"\"
195resume   = \"codex resume {session}\"
196";
197
198/// The order the built-in agents are probed against PATH when no `default` is
199/// configured: the first one both defined and installed wins (DESIGN.md §8).
200const DEFAULT_PROBE_ORDER: [&str; 2] = ["claude", "codex"];
201
202/// The parsed, validated built-in templates, layered under a user file by
203/// [`AgentsConfig::load`]. A parse or validation failure is a bug in
204/// [`BUILTIN_AGENTS`], so it panics rather than surfacing as a config error.
205fn builtin_agents() -> &'static BTreeMap<String, AgentTemplate> {
206    static BUILTINS: LazyLock<BTreeMap<String, AgentTemplate>> = LazyLock::new(|| {
207        let raw: RawConfig = toml::from_str(BUILTIN_AGENTS).expect("built-in agents TOML parses");
208        for (name, agent) in &raw.agents {
209            validate_agent(name, agent, Path::new("<built-in>")).expect("built-in agent is valid");
210        }
211        raw.agents
212    });
213    &BUILTINS
214}
215
216/// The viewers Voro ships with the binary, layered under any `voro.toml`
217/// exactly as [`BUILTIN_AGENTS`] is (DESIGN.md §11a), so a fresh install with
218/// an editor CLI on PATH opens a task's checkout with no config at all. A user
219/// `[viewers.<name>]` table of the same name replaces one wholesale.
220///
221/// Each opens its own window on a directory, because that is the only shape
222/// `open` can run: the viewer is spawned detached with no terminal (DESIGN.md
223/// §8), so a pager-driven command such as `git difftool -d` has nothing to draw
224/// on. They therefore take `{path}` alone rather than a `{base}...{branch}`
225/// range — an editor cannot open a diff range from its command line — which is
226/// what the README's review step promises anyway.
227const BUILTIN_VIEWERS: &str = "\
228[viewers.code]
229cmd = \"code -n {path}\"
230
231[viewers.cursor]
232cmd = \"cursor -n {path}\"
233
234[viewers.zed]
235cmd = \"zed {path}\"
236";
237
238/// The built-in viewers by name, in the order they are probed against PATH
239/// when nothing user-configured resolves: the first one installed wins
240/// (DESIGN.md §11a). Public because the messages that say what was looked for
241/// are written where the failure is surfaced, and none of them should spell
242/// the list again.
243pub const BUILTIN_VIEWER_NAMES: [&str; 3] = ["code", "cursor", "zed"];
244
245/// The parsed built-in viewers, layered under a user file the same way
246/// [`builtin_agents`] is. A malformed built-in is a bug here, not a user config
247/// error, so it panics.
248fn builtin_viewers() -> &'static BTreeMap<String, ViewerTemplate> {
249    static BUILTINS: LazyLock<BTreeMap<String, ViewerTemplate>> = LazyLock::new(|| {
250        let raw: RawConfig = toml::from_str(BUILTIN_VIEWERS).expect("built-in viewers TOML parses");
251        for (name, viewer) in &raw.viewers {
252            assert!(
253                !viewer.cmd.trim().is_empty(),
254                "built-in viewer '{name}' has a command"
255            );
256        }
257        for name in BUILTIN_VIEWER_NAMES {
258            assert!(
259                raw.viewers.contains_key(name),
260                "probe order names a built-in viewer, not '{name}'"
261            );
262        }
263        raw.viewers
264    });
265    &BUILTINS
266}
267
268/// Whether a name is a built-in viewer. The write surfaces ask, so removing or
269/// editing one is refused as "built in, override it" rather than reported as a
270/// viewer that isn't there.
271pub fn is_builtin_viewer(name: &str) -> bool {
272    builtin_viewers().contains_key(name)
273}
274
275/// A built-in viewer's command, so an override can start from what it replaces
276/// rather than from an empty field.
277pub fn builtin_viewer_cmd(name: &str) -> Option<&'static str> {
278    builtin_viewers().get(name).map(|v| v.cmd.as_str())
279}
280
281/// Header prose for the skeleton `agent init` writes. [`starter_config`]
282/// appends the current built-ins (commented) and example stanzas after it.
283const STARTER_HEADER: &str = r#"# Voro configuration (~/.config/voro/voro.toml).
284#
285# This file is OPTIONAL. Voro ships with built-in `claude` and `codex` agents
286# and built-in `code`, `cursor` and `zed` viewers, so a fresh install with any
287# of those on PATH dispatches and opens a diff with no config here. Run
288# `voro agent list` and `voro viewer list` to see the effective sets and where
289# each entry comes from.
290#
291# Use this file to extend or override the built-ins, and to set app options:
292#
293#   * add your own agent — a new [agents.<name>] table. Only `dispatch` is
294#     required (`cmd` is an alias): it starts a session on a task, with
295#     `{prompt_file}` replaced by the prompt file's path, the optional
296#     `{session_name}` by the name Voro composes for the session
297#     (`voro-<id>-<title-slug>` for a dispatch, `voro-<id>-refine` for a
298#     refine, `voro-plan-<project>` for planning, `voro-propose-<project>` for
299#     a quick propose), and the optional `{task_id}`
300#     by the task's numeric id.
301#     The optional session verbs unlock attachable dispatch, and each degrades
302#     gracefully when absent:
303#       sessions  list the agent's sessions as JSON (liveness + ref capture).
304#                 Each entry needs an id (`sessionId`, or `id`); `state`
305#                 (`done` once finished, `working` while going) and `pid` say
306#                 whether it is still live, `pid` deciding it where present.
307#                 An entry carrying neither reads as dead.
308#       attach    open a running session interactively    ({session})
309#       resume    reopen a finished session interactively  ({session})
310#       message   say one thing into a session headlessly, no terminal
311#                 ({session} and {prompt_file}, plus the optional
312#                 {new_session}: a fresh reference for an agent that can only
313#                 be joined by forking, which the session row then follows)
314#       logs      print a session's recent output               ({session})
315#                 Read for one thing: whether the session is sitting on a
316#                 usage cap, which is badged on the running strip and used to
317#                 tell a capped death from an ordinary one. Tail it in the
318#                 template — Voro reads whatever it prints.
319#       cap       print when the account's usage window reopens, as a Unix
320#                 epoch, while the account is capped — and nothing when it is
321#                 not. Read instead of the clock time on a session's screen,
322#                 which carries no date. It may name {model} and nothing else:
323#                 a subscription meters each strong model separately, so the
324#                 window that refuses depends on which one asks. Costs whatever
325#                 asking the agent costs, so Voro asks only while a session is
326#                 badged capped.
327#       stop      retire a session from the agent's own registry ({session})
328#                 Fired when Voro closes the session's row, so the agent's
329#                 listing shows work actually in flight. Fire and forget: Voro
330#                 reads neither output nor status, and a session already gone
331#                 is not an error.
332#       plan      run an interactive foreground planning session ({prompt_file})
333#     `plan` may carry `{session_name}` too, but not `{task_id}`: a planning
334#     session drafts a task rather than naming one.
335#     `dispatch` and `plan` may also carry `{model}`, filled from this agent's
336#     own model keys: `model` normally, `model_deep` for a task flagged deep
337#     (`voro set <id> --deep`), and `model_plan` when planning — the last two
338#     falling back to `model`. The values are opaque names Voro pastes in and
339#     never interprets, so an agent with no `{model}` takes no model direction
340#     and a deep task dispatches with it unchanged.
341#     See docs/agent-integration.md for the full contract.
342#   * override a built-in — a table named `claude` or `codex` REPLACES that
343#     built-in entirely (not per-verb), so copy every verb you still want. The
344#     built-ins are reproduced below, commented out, ready to copy.
345#   * set `default_agent` — used for tasks with no --agent override. When unset,
346#     Voro picks the first built-in found on PATH (claude, then codex).
347#   * set up viewers — [viewers.<name>] tables define how a task's diff is
348#     shown locally by `voro open` (DESIGN.md §8). A viewer cmd may carry
349#     `{path}` (the task's worktree, or the project checkout when it has none),
350#     `{branch}` (the task's branch, or empty), and `{base}` (the checkout's
351#     default branch); `{base}...{branch}` spells a diff range. Viewers are
352#     built in like the agents — a table named `code`, `cursor` or `zed`
353#     replaces that built-in wholesale, any other name adds a viewer.
354#     `default_viewer` names the one used when a project does not pick a viewer
355#     itself (`voro project viewer <p> <name>`); unset, Voro uses the sole
356#     viewer defined here, else the first built-in found on PATH. A single
357#     anonymous [viewer] table is the older, still-valid spelling of the
358#     default. A viewer must open its own window: `voro open` spawns it
359#     detached with no terminal, so a pager-driven command cannot draw.
360#   * price the queue — `max_running` caps how many dispatches ride at once
361#     (default 5; at the cap the queue offers no more), and a [costs] table
362#     divides each row's score by what its action asks of you, so a cheap
363#     decision outranks an expensive review of the same raw worth. Keep the
364#     band narrow (DESIGN.md §7) — it is a nudge, not a re-ranking.
365"#;
366
367/// The full skeleton `voro agent init` writes: the header, the built-ins
368/// reproduced commented-out (copyable to override or model an agent), then
369/// example stanzas. Every line is a comment, so the file defines nothing until
370/// the user uncomments something; the commented block is derived from
371/// [`BUILTIN_AGENTS`] so it cannot drift from what ships.
372fn starter_config() -> String {
373    let mut out = String::from(STARTER_HEADER);
374    out.push_str(
375        "\n# --------------------------------------------------------------------------\n\
376         # Built-in agents and viewers, exactly as shipped. Uncomment a block and\n\
377         # edit it to override that entry wholesale; leave it commented to keep the\n\
378         # built-in, which updates with Voro. Copy a block to model one of your own.\n\
379         # --------------------------------------------------------------------------\n#\n",
380    );
381    for line in BUILTIN_AGENTS
382        .lines()
383        .chain([""])
384        .chain(BUILTIN_VIEWERS.lines())
385    {
386        if line.is_empty() {
387            out.push_str("#\n");
388        } else {
389            out.push_str("# ");
390            out.push_str(line);
391            out.push('\n');
392        }
393    }
394    out.push_str(
395        "\n# --------------------------------------------------------------------------\n\
396         # Examples (uncomment and tune):\n#\n\
397         # default_agent = \"claude\"\n#\n\
398         # [agents.mine]\n\
399         # dispatch = \"my-agent run {prompt_file}\"\n#\n\
400         # default_viewer = \"zed\"\n#\n\
401         # [viewers.difftool]\n\
402         # cmd = \"git -C {path} difftool -d {base}...{branch}\"\n#\n\
403         # max_running = 5\n#\n\
404         # [costs]\n\
405         # answer = 0.8\n\
406         # triage = 0.8\n\
407         # dispatch = 1.0\n\
408         # review = 1.4\n\
409         # do = 1.8\n",
410    );
411    out
412}
413
414/// A named set of verb templates from `voro.toml`. `dispatch` (or its alias
415/// `cmd`) is required and always contains [`PROMPT_FILE_PLACEHOLDER`]; it may
416/// also carry the optional [`SESSION_NAME_PLACEHOLDER`] and
417/// [`TASK_ID_PLACEHOLDER`]. The rest are optional, with their
418/// `{session}`/`{prompt_file}` placeholders validated at parse time.
419#[derive(Debug, Clone, Deserialize)]
420#[serde(deny_unknown_fields)]
421pub struct AgentTemplate {
422    dispatch: Option<String>,
423    /// Pre-verb alias for `dispatch`, so existing configs load unchanged.
424    cmd: Option<String>,
425    sessions: Option<String>,
426    attach: Option<String>,
427    resume: Option<String>,
428    /// A *headless* send into an existing session, carrying both
429    /// [`SESSION_PLACEHOLDER`] and [`PROMPT_FILE_PLACEHOLDER`]: it appends one
430    /// message to that session's transcript and returns, owning no terminal
431    /// (DESIGN.md §8). What the TUI's quick-message key fires.
432    message: Option<String>,
433    /// A session's recent output, carrying [`SESSION_PLACEHOLDER`]: whatever
434    /// the agent can say about what one of its sessions is doing right now
435    /// (DESIGN.md §8). Voro reads it for one thing only — whether the session
436    /// is held at a usage cap ([`crate::read_cap`]) — so an agent that cannot
437    /// produce output for a session simply omits it, and Voro classifies a dead
438    /// session from the launch log as it always has and badges no live one.
439    logs: Option<String>,
440    /// When the account this agent dispatches on has its usage window reopen,
441    /// as a Unix epoch and nothing else (DESIGN.md §8). It names no session — a
442    /// cap is a property of the account, not of any one conversation — and
443    /// [`MODEL_PLACEHOLDER`] is the only placeholder it may carry, because
444    /// *which* window refuses depends on which model is asking: a subscription
445    /// meters the five-hour pool, the weekly one, and each strong model's own
446    /// allowance separately. A template that binds it is asked once per model in
447    /// flight; one that does not is asked once for the agent.
448    ///
449    /// Its contract is silence-as-negative like [`AgentTemplate::logs`]: print
450    /// the instant while the account is refused, print nothing otherwise. An
451    /// agent that cannot say — `codex` names none — leaves Voro reading the
452    /// reset time off the session's own screen, ambiguous by half a day, as it
453    /// always did.
454    cap: Option<String>,
455    /// Retire a session from the agent's own registry, carrying
456    /// [`SESSION_PLACEHOLDER`]: fired when Voro closes the session's row, so the
457    /// agent's listing converges on work actually in flight (DESIGN.md §8).
458    /// Fire-and-forget — Voro reads neither its output nor its status — and
459    /// wholly optional, since an agent that keeps no registry has nothing to
460    /// retire and one that keeps a listing it never prunes simply lingers as it
461    /// always did.
462    stop: Option<String>,
463    /// An interactive foreground command carrying [`PROMPT_FILE_PLACEHOLDER`],
464    /// run by the TUI's planning flow (DESIGN.md §8) — no `{session}`, since a
465    /// planning session belongs to no task or session row.
466    plan: Option<String>,
467    /// The agent-opaque model name substituted into [`MODEL_PLACEHOLDER`]: the
468    /// workhorse this agent runs work with, and the fallback for the two keys
469    /// below. Required once any template carries the placeholder.
470    model: Option<String>,
471    /// The stronger model a `deep` task dispatches with (DESIGN.md §8),
472    /// falling back to `model`.
473    model_deep: Option<String>,
474    /// The model the `plan` verb reasons with, falling back to `model`.
475    model_plan: Option<String>,
476}
477
478impl AgentTemplate {
479    /// The dispatch command — `dispatch`, or its legacy alias `cmd`.
480    /// Presence of exactly one is enforced at parse time.
481    pub fn dispatch(&self) -> &str {
482        self.dispatch
483            .as_deref()
484            .or(self.cmd.as_deref())
485            .expect("parse validates that dispatch or cmd is set")
486    }
487
488    pub fn sessions(&self) -> Option<&str> {
489        self.sessions.as_deref()
490    }
491
492    pub fn attach(&self) -> Option<&str> {
493        self.attach.as_deref()
494    }
495
496    pub fn resume(&self) -> Option<&str> {
497        self.resume.as_deref()
498    }
499
500    pub fn message(&self) -> Option<&str> {
501        self.message.as_deref()
502    }
503
504    pub fn logs(&self) -> Option<&str> {
505        self.logs.as_deref()
506    }
507
508    pub fn cap(&self) -> Option<&str> {
509        self.cap.as_deref()
510    }
511
512    pub fn stop(&self) -> Option<&str> {
513        self.stop.as_deref()
514    }
515
516    pub fn plan(&self) -> Option<&str> {
517        self.plan.as_deref()
518    }
519
520    pub fn model(&self) -> Option<&str> {
521        self.model.as_deref()
522    }
523
524    pub fn model_deep(&self) -> Option<&str> {
525        self.model_deep.as_deref()
526    }
527
528    pub fn model_plan(&self) -> Option<&str> {
529        self.model_plan.as_deref()
530    }
531
532    /// The model a launch of this agent at the given depth runs with, by the
533    /// same rule [`ResolvedAgent::launch_command`] resolves it by — shared so
534    /// the two cannot drift, since anything asking *about* a session has to
535    /// name the model that session actually started under.
536    pub fn model_for(&self, deep: bool) -> Option<&str> {
537        model_for_depth(self.model(), self.model_deep(), deep)
538    }
539
540    /// The optional verbs this agent defines, in roster order, as `agent list`
541    /// and the Config screen name them (DESIGN.md §8). A `message` that carries
542    /// [`NEW_SESSION_PLACEHOLDER`] reads `message(fork)`, because forking is
543    /// what a send into a supervisor-held session needs and it moves the
544    /// session reference the row afterwards addresses.
545    pub fn verbs(&self) -> Vec<&'static str> {
546        OPTIONAL_VERBS
547            .iter()
548            .filter_map(|(verb, defined)| {
549                let template = defined(self)?;
550                Some(
551                    if *verb == "message" && template.contains(NEW_SESSION_PLACEHOLDER) {
552                        "message(fork)"
553                    } else {
554                        *verb
555                    },
556                )
557            })
558            .collect()
559    }
560}
561
562/// A verb's name beside the accessor for its template.
563type VerbAccessor = (&'static str, fn(&AgentTemplate) -> Option<&str>);
564
565/// Every verb an agent may define beyond `dispatch`, in the order they are
566/// listed to the operator. One roster serves both the positive listing and the
567/// dropped-verb warning under it, so the two lines cannot disagree about the
568/// same agent.
569const OPTIONAL_VERBS: [VerbAccessor; 8] = [
570    ("sessions", AgentTemplate::sessions),
571    ("attach", AgentTemplate::attach),
572    ("resume", AgentTemplate::resume),
573    ("message", AgentTemplate::message),
574    ("logs", AgentTemplate::logs),
575    ("cap", AgentTemplate::cap),
576    ("stop", AgentTemplate::stop),
577    ("plan", AgentTemplate::plan),
578];
579
580/// What a launch *is* (DESIGN.md §8): the one place a backgrounded or
581/// foreground agent session's identity is composed. A launch names its session,
582/// its prompt and log files, and its line in the launch log from this single
583/// value, so a new flavour of launch cannot inherit one of those and forget
584/// another — which is exactly how a refine came to be named `voro-{task_id}`,
585/// literally, on every task at once.
586///
587/// The invariant it carries: every session Voro launches has a Voro-composed
588/// name starting `voro-<id>` — a dispatch continues into a slug of the task's
589/// title, anything else pointed at that task into its kind — so nothing Voro
590/// starts shows up anonymous or duplicately named in the agent's own session
591/// listing. A launch that belongs to no task is named for its project — by
592/// name, not id, since a bare number there would read as a task id.
593#[derive(Debug, Clone, PartialEq, Eq)]
594pub enum Launch {
595    /// A task dispatched to a headless session (DESIGN.md §8), carrying the
596    /// task's title so the session name can say what it is working on.
597    Dispatch { task_id: i64, title: String },
598    /// A proposed task's body rewritten by an agent (DESIGN.md §6), at either
599    /// intensity — the headless note-driven one and the interactive one are the
600    /// same operation, and only one of them is ever backgrounded.
601    Refine { task_id: i64 },
602    /// An interactive planning session drafting a new task for a project,
603    /// carrying the project's name. Project names are unique (schema §5), so
604    /// two projects cannot claim one session name.
605    Plan { project: String },
606    /// A headless agent expanding the operator's one-line intent into a task it
607    /// files itself (DESIGN.md §6/§8). Like a planning session it belongs to no
608    /// task — it is drafting one — so it names its project, by name, and the
609    /// two task-less launches share that one convention: a bare number in a
610    /// Voro-composed session name is always a task id.
611    Propose { project: String },
612}
613
614impl Launch {
615    /// The name the agent's session carries, filling [`SESSION_NAME_PLACEHOLDER`].
616    /// `voro-<id>-<slug>` for a dispatch is the published contract
617    /// (docs/agent-integration.md) and what `attach` and the `/resume` picker
618    /// are read by, so anything else pointed at the same task suffixes a kind
619    /// rather than colliding with it. The slug says what the task *is*, which
620    /// is all the operator gets in the agents view and on the phone; a title
621    /// that survives sanitization to nothing leaves the bare `voro-<id>`.
622    pub fn session_name(&self) -> String {
623        match self {
624            Launch::Dispatch { task_id, title } => match title_slug(title) {
625                Some(slug) => format!("voro-{task_id}-{slug}"),
626                None => format!("voro-{task_id}"),
627            },
628            Launch::Refine { task_id } => format!("voro-{task_id}-refine"),
629            Launch::Plan { project } => format!("voro-plan-{}", sanitize_for_name(project)),
630            Launch::Propose { project } => format!("voro-propose-{}", sanitize_for_name(project)),
631        }
632    }
633
634    /// The stem of this launch's prompt and log files, and the label its
635    /// launch-log lines carry.
636    pub fn slug(&self) -> String {
637        match self {
638            Launch::Dispatch { task_id, .. } => format!("task-{task_id}"),
639            Launch::Refine { task_id } => format!("refine-{task_id}"),
640            Launch::Plan { project } => format!("plan-{}", sanitize_for_name(project)),
641            Launch::Propose { project } => format!("propose-{}", sanitize_for_name(project)),
642        }
643    }
644
645    /// The task this launch is pointed at, if any — `None` for the two launches
646    /// that draft a task rather than naming one, and why
647    /// [`TASK_ID_PLACEHOLDER`] is refused on the `plan` verb.
648    pub fn task_id(&self) -> Option<i64> {
649        match self {
650            Launch::Dispatch { task_id, .. } | Launch::Refine { task_id } => Some(*task_id),
651            Launch::Plan { .. } | Launch::Propose { .. } => None,
652        }
653    }
654}
655
656/// Reduce a free-text name to what a session name and a filename can both
657/// safely carry: every character outside `[A-Za-z0-9._-]` becomes `-`. A
658/// session name is substituted into a shell command line and its slug becomes a
659/// filename, so a project called `my stuff` or `it's "fine"` must not reach
660/// either as written. Case is preserved, so `voro-plan-ODM` stays readable.
661/// Two names that reduce to the same string is a collision Voro accepts:
662/// project names are unique, and `a b` alongside `a-b` is not a real case.
663fn sanitize_for_name(name: &str) -> String {
664    name.chars()
665        .map(|c| {
666            if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
667                c
668            } else {
669                '-'
670            }
671        })
672        .collect()
673}
674
675/// The kind suffixes a per-task session name may carry after `voro-<id>`. A
676/// dispatch of a task titled "Refine the scheduler" must not slug down to
677/// `voro-<id>-refine` and land on the name that task's own refine round would
678/// take.
679const RESERVED_NAME_SUFFIXES: &[&str] = &["refine"];
680
681/// How long a dispatch's title slug may grow before it stops taking words. The
682/// agents view and the phone's session list both truncate, so the first words
683/// have to carry the meaning and the rest are noise. Around twenty characters:
684/// the width that takes the three-word phrase most titles open with.
685const TITLE_SLUG_BUDGET: usize = 24;
686
687/// Reduce a task title to the tail of its session name: whole words from the
688/// front, lowercased and sanitized the same way a project name is, joined with
689/// `-` and stopped before the budget is exceeded. Always at least one word,
690/// even an over-long one — a name cut mid-word reads as a different task.
691/// `None` where nothing survives, which a title of punctuation or of a script
692/// outside `[A-Za-z0-9._-]` produces; the caller falls back to the bare
693/// `voro-<id>`.
694fn title_slug(title: &str) -> Option<String> {
695    let words: Vec<String> = title
696        .split_whitespace()
697        .map(|word| tidy_dashes(&sanitize_for_name(&word.to_lowercase())))
698        .filter(|word| !word.is_empty())
699        .collect();
700
701    let mut slug = String::new();
702    let mut taken = 0;
703    for word in &words {
704        if !slug.is_empty() && slug.len() + 1 + word.len() > TITLE_SLUG_BUDGET {
705            break;
706        }
707        if !slug.is_empty() {
708            slug.push('-');
709        }
710        slug.push_str(word);
711        taken += 1;
712    }
713
714    if RESERVED_NAME_SUFFIXES.contains(&slug.as_str()) {
715        // Over budget by a word, which beats colliding with a kind name; a
716        // title with no further word to give falls back to the bare name.
717        let next = words.get(taken)?;
718        slug.push('-');
719        slug.push_str(next);
720    }
721
722    (!slug.is_empty()).then_some(slug)
723}
724
725/// Collapse runs of `-` and drop them from both ends. A title is prose rather
726/// than a handle, so sanitizing its punctuation leaves dashes where a project
727/// name would never have them: `it's "fine"` reads better as `it-s-fine` than
728/// as `it-s--fine-`.
729fn tidy_dashes(word: &str) -> String {
730    let mut out = String::with_capacity(word.len());
731    for c in word.chars() {
732        if c == '-' && (out.is_empty() || out.ends_with('-')) {
733            continue;
734        }
735        out.push(c);
736    }
737    out.trim_end_matches('-').to_string()
738}
739
740/// Everything a verb template needs bound to become a command line: which
741/// launch this is, the prompt file written for it, and whether the task earns
742/// the deeper model. Assembled by the caller that wrote the prompt, rendered by
743/// [`ResolvedAgent::launch_command`] or
744/// [`ResolvedAgent::plan_launch_command`](ResolvedAgent::plan_launch_command).
745#[derive(Debug, Clone)]
746pub struct LaunchSpec<'a> {
747    pub launch: Launch,
748    pub prompt_file: &'a Path,
749    /// Whether the task carries the `deep` flag; ignored by the plan template,
750    /// which has no depth to read.
751    pub deep: bool,
752}
753
754/// Bind every launch placeholder a verb template may carry, in one pass, so no
755/// value's own braces are re-scanned. `{task_id}` goes unbound for a launch that
756/// has none, which only a `plan` template could contain — and that is refused at
757/// config load.
758fn render_launch(template: &str, spec: &LaunchSpec, model: Option<&str>) -> String {
759    let prompt_file = shell_quote(spec.prompt_file);
760    let session_name = spec.launch.session_name();
761    let task_id = spec.launch.task_id().map(|id| id.to_string());
762    let mut bindings = vec![
763        (PROMPT_FILE_PLACEHOLDER, prompt_file.as_str()),
764        (SESSION_NAME_PLACEHOLDER, session_name.as_str()),
765    ];
766    if let Some(task_id) = &task_id {
767        bindings.push((TASK_ID_PLACEHOLDER, task_id.as_str()));
768    }
769    if let Some(model) = model {
770        bindings.push((MODEL_PLACEHOLDER, model));
771    }
772    render(template, &bindings)
773}
774
775/// A `message` template rendered into a runnable command line, plus the
776/// reference the session will answer to afterwards where the agent forks
777/// ([`NEW_SESSION_PLACEHOLDER`]). The caller records that reference only once
778/// the send is under way, so a command that never ran leaves the session
779/// pointing where it did.
780#[derive(Debug, Clone, PartialEq, Eq)]
781pub struct RenderedMessage {
782    pub command: String,
783    /// The fresh reference bound to `{new_session}`, or `None` for a template
784    /// that resumes its session in place.
785    pub new_session_ref: Option<String>,
786}
787
788/// Bind a session verb's one placeholder: the reference Voro captured at
789/// launch, shell-quoted so a reference carrying shell metacharacters reaches
790/// the agent as itself. Serves `logs`, whose whole contract is a session in and
791/// that session's recent output out.
792/// Which model a launch at a given depth runs with (DESIGN.md §8): the deeper
793/// one for a deep task where the agent names one, the workhorse otherwise. The
794/// one place that rule lives, because two callers now depend on agreeing about
795/// it — the launch itself, and the `cap` reading that has to ask about the
796/// window *that* model is metered against.
797pub fn model_for_depth<'a>(
798    model: Option<&'a str>,
799    model_deep: Option<&'a str>,
800    deep: bool,
801) -> Option<&'a str> {
802    if deep { model_deep.or(model) } else { model }
803}
804
805/// A `cap` template rendered into a runnable command line (DESIGN.md §8). The
806/// model is bound exactly as a launch binds it — pasted in as the opaque name
807/// the operator configured, Voro being model-blind — and a template naming no
808/// model renders unchanged, which is what makes the per-model question optional
809/// rather than required.
810pub fn render_cap(template: &str, model: Option<&str>) -> String {
811    match model {
812        Some(model) => render(template, &[(MODEL_PLACEHOLDER, model)]),
813        None => template.to_string(),
814    }
815}
816
817pub fn render_session(template: &str, session_ref: &str) -> String {
818    let session = shell_quote(Path::new(session_ref));
819    render(template, &[(SESSION_PLACEHOLDER, session.as_str())])
820}
821
822/// Bind a `message` template's placeholders in one pass, so no value's own
823/// braces are re-scanned: the session reference Voro captured at dispatch, the
824/// file holding the message, and — for a template that forks — a freshly
825/// generated v4 UUID for the session the send opens. All are shell-quoted; the
826/// references are agent-opaque text, not tokens Voro may assume are bare.
827pub fn render_message(template: &str, session_ref: &str, prompt_file: &Path) -> RenderedMessage {
828    let session = shell_quote(Path::new(session_ref));
829    let prompt_file = shell_quote(prompt_file);
830    let new_session_ref = template
831        .contains(NEW_SESSION_PLACEHOLDER)
832        .then(|| uuid::Uuid::new_v4().to_string());
833    let new_session = new_session_ref
834        .as_deref()
835        .map(|r| shell_quote(Path::new(r)));
836    let mut bindings = vec![
837        (SESSION_PLACEHOLDER, session.as_str()),
838        (PROMPT_FILE_PLACEHOLDER, prompt_file.as_str()),
839    ];
840    if let Some(new_session) = &new_session {
841        bindings.push((NEW_SESSION_PLACEHOLDER, new_session.as_str()));
842    }
843    RenderedMessage {
844        command: render(template, &bindings),
845        new_session_ref,
846    }
847}
848
849/// A viewer command template from `voro.toml` (DESIGN.md §11a): a shell command
850/// run in a task's checkout — or its worktree — to open its diff. Defined as a
851/// named `[viewers.<name>]` table or the anonymous `[viewer]` default. The
852/// placeholders `{path}` (checkout/worktree dir), `{branch}` (the task's
853/// branch), and `{base}` (the checkout's default branch) are all optional, so
854/// nothing is validated at parse time.
855#[derive(Debug, Clone, Deserialize)]
856#[serde(deny_unknown_fields)]
857pub struct ViewerTemplate {
858    pub cmd: String,
859}
860
861/// Where an effective agent came from once the built-ins and `voro.toml`
862/// are layered, surfaced by `voro agent list` so it is clear which half of
863/// the config owns each agent.
864#[derive(Debug, Clone, Copy, PartialEq, Eq)]
865pub enum Provenance {
866    /// Ships with the binary; no user file mentions it.
867    BuiltIn,
868    /// Defined only in the user's `voro.toml`.
869    User,
870    /// A user table that replaces a built-in of the same name wholesale.
871    UserOverride,
872}
873
874impl Provenance {
875    /// A short label for `agent list`.
876    pub fn label(self) -> &'static str {
877        match self {
878            Provenance::BuiltIn => "built-in",
879            Provenance::User => "user",
880            Provenance::UserOverride => "user override",
881        }
882    }
883}
884
885/// The raw shape deserialized from `voro.toml` (or the built-in TOML) before
886/// layering. Every field is optional, so a file that only sets `[viewer]`, only
887/// adds an agent, or is empty all parse.
888#[derive(Debug, Deserialize)]
889#[serde(deny_unknown_fields)]
890struct RawConfig {
891    #[serde(default)]
892    default_agent: Option<String>,
893    #[serde(default)]
894    agents: BTreeMap<String, AgentTemplate>,
895    #[serde(default)]
896    viewer: Option<ViewerTemplate>,
897    #[serde(default)]
898    viewers: BTreeMap<String, ViewerTemplate>,
899    #[serde(default)]
900    default_viewer: Option<String>,
901    #[serde(default)]
902    max_running: Option<i64>,
903    #[serde(default)]
904    costs: Option<RawCosts>,
905}
906
907/// The `[costs]` table (DESIGN.md §7): per-action overrides of the attention
908/// price band. Every key is optional and falls back to the built-in default,
909/// so a table naming one action leaves the rest alone.
910#[derive(Debug, Deserialize)]
911#[serde(deny_unknown_fields)]
912struct RawCosts {
913    answer: Option<f64>,
914    triage: Option<f64>,
915    dispatch: Option<f64>,
916    review: Option<f64>,
917    /// Spelled `do` in the file, after the verb a human task's row asks for.
918    #[serde(rename = "do")]
919    human_do: Option<f64>,
920}
921
922impl RawCosts {
923    /// Layer the file's overrides onto the defaults, rejecting a divisor that
924    /// would invert or blow up the ranking.
925    fn resolve(self, path: &Path) -> Result<AttentionCosts> {
926        let defaults = AttentionCosts::default();
927        let checked = |name: &str, value: Option<f64>, default: f64| -> Result<f64> {
928            match value {
929                None => Ok(default),
930                Some(value) if value.is_finite() && value > 0.0 => Ok(value),
931                Some(value) => Err(Error::AgentConfigInvalid {
932                    path: path.to_path_buf(),
933                    message: format!(
934                        "cost '{name}' is {value} — every [costs] divisor must be a positive \
935                         number (the defaults sit between 0.8 and 1.8)"
936                    ),
937                }),
938            }
939        };
940        Ok(AttentionCosts {
941            answer: checked("answer", self.answer, defaults.answer)?,
942            triage: checked("triage", self.triage, defaults.triage)?,
943            dispatch: checked("dispatch", self.dispatch, defaults.dispatch)?,
944            review: checked("review", self.review, defaults.review)?,
945            human_do: checked("do", self.human_do, defaults.human_do)?,
946        })
947    }
948}
949
950/// Why a negative dispatch cap is refused, in one place: the file and the
951/// Config screen's editor (DESIGN.md §5) both write the cap, and an operator
952/// who meets the refusal at one surface should meet the same sentence at the
953/// other.
954pub(crate) fn negative_max_running(n: i64) -> String {
955    format!(
956        "max_running is {n} — it counts dispatches in flight, so it cannot be negative (0 stops \
957         the queue offering dispatches at all)"
958    )
959}
960
961/// Validate one agent's verb templates, shared by the built-ins and the user
962/// file. `dispatch` (or its alias `cmd`) must be present and carry the
963/// prompt-file placeholder; the session verbs carry their placeholders when
964/// present.
965fn validate_agent(name: &str, agent: &AgentTemplate, path: &Path) -> Result<()> {
966    let invalid = |message: String| Error::AgentConfigInvalid {
967        path: path.to_path_buf(),
968        message,
969    };
970    let dispatch = match (&agent.dispatch, &agent.cmd) {
971        (Some(_), Some(_)) => {
972            return Err(invalid(format!(
973                "agent '{name}' sets both dispatch and cmd — cmd is an alias for \
974                 dispatch, keep one"
975            )));
976        }
977        (Some(d), None) => d,
978        (None, Some(c)) => c,
979        (None, None) => {
980            return Err(invalid(format!(
981                "agent '{name}' is missing a dispatch (or cmd) template"
982            )));
983        }
984    };
985    if !dispatch.contains(PROMPT_FILE_PLACEHOLDER) {
986        return Err(invalid(format!(
987            "agent '{name}' cmd is missing the {PROMPT_FILE_PLACEHOLDER} placeholder"
988        )));
989    }
990    for (verb, template) in [
991        ("attach", &agent.attach),
992        ("resume", &agent.resume),
993        ("message", &agent.message),
994        ("logs", &agent.logs),
995        ("stop", &agent.stop),
996    ] {
997        if let Some(template) = template
998            && !template.contains(SESSION_PLACEHOLDER)
999        {
1000            return Err(invalid(format!(
1001                "agent '{name}' {verb} is missing the {SESSION_PLACEHOLDER} placeholder"
1002            )));
1003        }
1004    }
1005    // `message` carries a prompt as well as a session: it says something into
1006    // an existing conversation, so it needs both halves.
1007    for (verb, template) in [("plan", &agent.plan), ("message", &agent.message)] {
1008        if let Some(template) = template
1009            && !template.contains(PROMPT_FILE_PLACEHOLDER)
1010        {
1011            return Err(invalid(format!(
1012                "agent '{name}' {verb} is missing the {PROMPT_FILE_PLACEHOLDER} placeholder"
1013            )));
1014        }
1015    }
1016    // `{model}`, `{session_name}` and `{task_id}` are all resolved only where a
1017    // command launches work, so they are meaningful on `dispatch` and `plan`
1018    // and nowhere else; on a session verb they would reach the shell as literal
1019    // braces. No launch placeholder may survive to a command line: either a
1020    // renderer binds it or it is refused here.
1021    for (verb, template) in [
1022        ("sessions", &agent.sessions),
1023        ("attach", &agent.attach),
1024        ("resume", &agent.resume),
1025        ("message", &agent.message),
1026        ("logs", &agent.logs),
1027        ("stop", &agent.stop),
1028    ] {
1029        let Some(template) = template else { continue };
1030        if template.contains(MODEL_PLACEHOLDER) {
1031            return Err(invalid(format!(
1032                "agent '{name}' {verb} carries {MODEL_PLACEHOLDER}, which is resolved only on \
1033                 dispatch and plan — a session verb reuses the model its session started with"
1034            )));
1035        }
1036        for placeholder in [SESSION_NAME_PLACEHOLDER, TASK_ID_PLACEHOLDER] {
1037            if template.contains(placeholder) {
1038                return Err(invalid(format!(
1039                    "agent '{name}' {verb} carries {placeholder}, which is resolved only on \
1040                     dispatch and plan — a session verb names its session with {SESSION_PLACEHOLDER}, \
1041                     the reference Voro captured at launch"
1042                )));
1043            }
1044        }
1045    }
1046    // `cap` asks about the account rather than about a session or a launch, so
1047    // every placeholder but `{model}` is a category error there: nothing binds
1048    // it, and it would reach the shell as literal braces. `{model}` is the
1049    // exception because which window refuses depends on which model asks.
1050    if let Some(template) = &agent.cap {
1051        for placeholder in [
1052            SESSION_PLACEHOLDER,
1053            PROMPT_FILE_PLACEHOLDER,
1054            SESSION_NAME_PLACEHOLDER,
1055            TASK_ID_PLACEHOLDER,
1056            NEW_SESSION_PLACEHOLDER,
1057        ] {
1058            if template.contains(placeholder) {
1059                return Err(invalid(format!(
1060                    "agent '{name}' cap carries {placeholder}, but a cap reading is about the \
1061                     account rather than any one session or launch — {MODEL_PLACEHOLDER} is the \
1062                     only placeholder it may name"
1063                )));
1064            }
1065        }
1066    }
1067    // `{new_session}` names the session a *send* opens, so `message` is the one
1068    // verb that can bind it; anywhere else it would reach the shell as literal
1069    // braces.
1070    for (verb, template) in [
1071        ("dispatch", Some(dispatch.as_str())),
1072        ("sessions", agent.sessions.as_deref()),
1073        ("attach", agent.attach.as_deref()),
1074        ("resume", agent.resume.as_deref()),
1075        ("logs", agent.logs.as_deref()),
1076        ("stop", agent.stop.as_deref()),
1077        ("plan", agent.plan.as_deref()),
1078    ] {
1079        if template.is_some_and(|t| t.contains(NEW_SESSION_PLACEHOLDER)) {
1080            return Err(invalid(format!(
1081                "agent '{name}' {verb} carries {NEW_SESSION_PLACEHOLDER}, which is bound only on \
1082                 message — it names the session a headless send forks into"
1083            )));
1084        }
1085    }
1086    // `plan` serves a target that has no task: a planning session drafts a task
1087    // rather than naming one, so `{task_id}` there has nothing to bind to. A
1088    // template must render for every target its verb serves.
1089    if let Some(template) = &agent.plan
1090        && template.contains(TASK_ID_PLACEHOLDER)
1091    {
1092        return Err(invalid(format!(
1093            "agent '{name}' plan carries {TASK_ID_PLACEHOLDER}, but a planning session drafts a \
1094             task rather than naming one — use {SESSION_NAME_PLACEHOLDER}, which Voro composes \
1095             for every launch"
1096        )));
1097    }
1098    // The model keys are inert without the placeholder (a wholesale override
1099    // that drops `{model}` keeps loading), but the placeholder without them
1100    // has nothing to resolve to.
1101    if agent.model.is_none()
1102        && [
1103            dispatch.as_str(),
1104            agent.plan.as_deref().unwrap_or_default(),
1105            agent.cap.as_deref().unwrap_or_default(),
1106        ]
1107        .iter()
1108        .any(|t| t.contains(MODEL_PLACEHOLDER))
1109    {
1110        return Err(invalid(format!(
1111            "agent '{name}' uses {MODEL_PLACEHOLDER} but sets no model — add model = \
1112             \"<model name>\" to its [agents.{name}] table (optionally model_deep for deep \
1113             tasks and model_plan for planning), or drop the placeholder"
1114        )));
1115    }
1116    Ok(())
1117}
1118
1119/// Whether an executable named `name` is on `PATH`, for picking a default agent
1120/// when the user file names none. The probe is by agent name, which for the
1121/// built-ins is also the binary name.
1122fn binary_on_path(name: &str) -> bool {
1123    let Some(paths) = std::env::var_os("PATH") else {
1124        return false;
1125    };
1126    std::env::split_paths(&paths).any(|dir| dir.join(name).is_file())
1127}
1128
1129/// The first built-in viewer installed, the last resort of viewer resolution
1130/// (DESIGN.md §11a). Probed by viewer name, which for the built-ins is also the
1131/// binary name — a user table overriding one keeps that name, so an override
1132/// changes what runs, not whether the probe finds it.
1133fn probed_builtin_viewer(probe: &dyn Fn(&str) -> bool) -> Option<&'static str> {
1134    BUILTIN_VIEWER_NAMES.into_iter().find(|name| probe(name))
1135}
1136
1137/// The agent a task will be dispatched with: the task's own override if it
1138/// has one, otherwise the config's global default, with every verb template
1139/// resolved.
1140///
1141/// The `dispatch` and `plan` fields still hold their placeholders unresolved,
1142/// because what they bind to depends on the launch: reach them through
1143/// [`launch_command`](Self::launch_command) and
1144/// [`plan_launch_command`](Self::plan_launch_command), which return a command
1145/// line with nothing left to substitute.
1146#[derive(Debug, Clone, PartialEq, Eq)]
1147pub struct ResolvedAgent {
1148    pub name: String,
1149    pub dispatch: String,
1150    pub sessions: Option<String>,
1151    pub attach: Option<String>,
1152    pub resume: Option<String>,
1153    pub message: Option<String>,
1154    pub logs: Option<String>,
1155    pub cap: Option<String>,
1156    pub stop: Option<String>,
1157    pub plan: Option<String>,
1158    pub model: Option<String>,
1159    pub model_deep: Option<String>,
1160    pub model_plan: Option<String>,
1161}
1162
1163impl ResolvedAgent {
1164    /// The dispatch template rendered into a runnable command line: the prompt
1165    /// file shell-quoted, the launch's session name and task id bound, and
1166    /// `{model}` resolved for the task's depth (DESIGN.md §8) — `model_deep` for
1167    /// a deep task, falling back to `model` when the agent names no deeper one,
1168    /// and `model` otherwise. An agent whose template carries no placeholder
1169    /// renders the same string either way, the graceful degradation of the
1170    /// `deep` flag.
1171    pub fn launch_command(&self, spec: &LaunchSpec) -> String {
1172        let model = model_for_depth(self.model.as_deref(), self.model_deep.as_deref(), spec.deep);
1173        render_launch(&self.dispatch, spec, model)
1174    }
1175
1176    /// Which liveness source a session launched through this agent's
1177    /// `dispatch` template must be read by (DESIGN.md §8), recorded on the
1178    /// session row at launch. An agent defining a `sessions` verb is one whose
1179    /// launch may hand the work to a supervisor — `claude --bg` does — leaving
1180    /// Voro holding a launcher pid that dies at birth, so its listing is the
1181    /// only source that can answer. An agent without the verb has no listing to
1182    /// consult, and its spawned pid is all there is.
1183    ///
1184    /// This is the *headless* launch's answer, which the interactive `plan`
1185    /// verb does not share: that one is a foreground child Voro owns, so its
1186    /// caller records [`LivenessSource::Pid`] itself.
1187    pub fn dispatch_liveness_source(&self) -> LivenessSource {
1188        match self.sessions {
1189            Some(_) => LivenessSource::Listing,
1190            None => LivenessSource::Pid,
1191        }
1192    }
1193
1194    /// The plan template rendered the same way, when the agent defines the
1195    /// verb, with `{model}` resolved to `model_plan` falling back to `model`.
1196    /// Planning has no depth: it is interactive reasoning either way, so
1197    /// `spec.deep` is not read.
1198    pub fn plan_launch_command(&self, spec: &LaunchSpec) -> Option<String> {
1199        let model = self.model_plan.as_deref().or(self.model.as_deref());
1200        self.plan
1201            .as_deref()
1202            .map(|template| render_launch(template, spec, model))
1203    }
1204}
1205
1206/// The effective agent config: the built-in agents with any `voro.toml`
1207/// merged on top, plus the user's `default_agent` and viewers. Each agent
1208/// carries its [`Provenance`] so `agent list` can show where it came from.
1209#[derive(Debug, Clone)]
1210pub struct AgentsConfig {
1211    /// The user-set `default_agent`, if any; `None` falls back to a PATH probe.
1212    default: Option<String>,
1213    agents: BTreeMap<String, AgentTemplate>,
1214    provenance: BTreeMap<String, Provenance>,
1215    /// The anonymous `[viewer]` table — the pre-names single viewer, still
1216    /// honoured as a default when no `default_viewer` is set.
1217    viewer: Option<ViewerTemplate>,
1218    /// The named `[viewers.<name>]` tables a project can pick from
1219    /// (DESIGN.md §8/§11a).
1220    viewers: BTreeMap<String, ViewerTemplate>,
1221    /// The user-set `default_viewer`, naming a `[viewers.*]` entry.
1222    default_viewer: Option<String>,
1223    /// The attention price band the queue ranks by (DESIGN.md §7), defaults
1224    /// with any `[costs]` overrides layered on.
1225    costs: AttentionCosts,
1226    /// How many dispatches ride at once before the queue stops offering more,
1227    /// as the file spells it — `None` when the key is absent, which is what
1228    /// lets the Config screen say whether the cap in force is the operator's
1229    /// or Voro's own (DESIGN.md §5).
1230    max_running: Option<i64>,
1231    path: PathBuf,
1232}
1233
1234/// The config filename under the `voro/` config directory.
1235const CONFIG_FILENAME: &str = "voro.toml";
1236
1237impl AgentsConfig {
1238    /// The config path dispatch reads: `$XDG_CONFIG_HOME/voro/voro.toml`,
1239    /// defaulting to `~/.config`. A fresh install resolves here even before
1240    /// the file exists — that is the path `agent init` writes.
1241    pub fn default_path() -> PathBuf {
1242        let config_home = std::env::var_os("XDG_CONFIG_HOME")
1243            .map(PathBuf::from)
1244            .filter(|p| p.is_absolute())
1245            .unwrap_or_else(|| {
1246                let home = std::env::var_os("HOME")
1247                    .map(PathBuf::from)
1248                    .unwrap_or_default();
1249                home.join(".config")
1250            });
1251        config_home.join("voro").join(CONFIG_FILENAME)
1252    }
1253
1254    /// Load the effective config: the built-in agents, with the user file
1255    /// layered on top if it exists. A missing file is not an error — the
1256    /// built-ins alone dispatch — so a fresh install needs no `agent init`.
1257    pub fn load(path: &Path) -> Result<AgentsConfig> {
1258        match std::fs::read_to_string(path) {
1259            Ok(text) => AgentsConfig::parse(&text, path),
1260            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1261                Ok(AgentsConfig::builtin_only(path))
1262            }
1263            Err(e) => Err(Error::AgentConfigInvalid {
1264                path: path.to_path_buf(),
1265                message: e.to_string(),
1266            }),
1267        }
1268    }
1269
1270    /// The built-in agents alone, with no user file layered on. Used when
1271    /// the config file is absent.
1272    fn builtin_only(path: &Path) -> AgentsConfig {
1273        let agents = builtin_agents().clone();
1274        let provenance = agents
1275            .keys()
1276            .map(|name| (name.clone(), Provenance::BuiltIn))
1277            .collect();
1278        AgentsConfig {
1279            default: None,
1280            agents,
1281            provenance,
1282            viewer: None,
1283            viewers: BTreeMap::new(),
1284            default_viewer: None,
1285            costs: AttentionCosts::default(),
1286            max_running: None,
1287            path: path.to_path_buf(),
1288        }
1289    }
1290
1291    /// Parse the user file's text and layer it over the built-ins: a user
1292    /// table replaces a built-in of the same name wholesale (whole-agent
1293    /// override), otherwise it adds a new agent. `default_agent`/`[viewer]`
1294    /// come from the file.
1295    fn parse(text: &str, path: &Path) -> Result<AgentsConfig> {
1296        let raw: RawConfig = toml::from_str(text).map_err(|e| Error::AgentConfigInvalid {
1297            path: path.to_path_buf(),
1298            message: e.message().to_string(),
1299        })?;
1300        for (name, agent) in &raw.agents {
1301            validate_agent(name, agent, path)?;
1302        }
1303        let mut agents = builtin_agents().clone();
1304        let mut provenance: BTreeMap<String, Provenance> = agents
1305            .keys()
1306            .map(|name| (name.clone(), Provenance::BuiltIn))
1307            .collect();
1308        for (name, agent) in raw.agents {
1309            let prov = if builtin_agents().contains_key(&name) {
1310                Provenance::UserOverride
1311            } else {
1312                Provenance::User
1313            };
1314            provenance.insert(name.clone(), prov);
1315            agents.insert(name, agent);
1316        }
1317        if let Some(n) = raw.max_running
1318            && n < 0
1319        {
1320            return Err(Error::AgentConfigInvalid {
1321                path: path.to_path_buf(),
1322                message: negative_max_running(n),
1323            });
1324        }
1325        let costs = match raw.costs {
1326            Some(costs) => costs.resolve(path)?,
1327            None => AttentionCosts::default(),
1328        };
1329        Ok(AgentsConfig {
1330            default: raw.default_agent,
1331            agents,
1332            provenance,
1333            viewer: raw.viewer,
1334            viewers: raw.viewers,
1335            default_viewer: raw.default_viewer,
1336            costs,
1337            max_running: raw.max_running,
1338            path: path.to_path_buf(),
1339        })
1340    }
1341
1342    /// The attention price band the queue ranks by (DESIGN.md §7).
1343    pub fn costs(&self) -> AttentionCosts {
1344        self.costs
1345    }
1346
1347    /// The dispatch WIP cap (DESIGN.md §7): how many tasks may be running
1348    /// before the queue stops offering dispatches.
1349    pub fn max_running(&self) -> i64 {
1350        self.max_running.unwrap_or(DEFAULT_MAX_RUNNING)
1351    }
1352
1353    /// The cap as `voro.toml` spells it, `None` when the file names none — the
1354    /// provenance the Config screen's settings list shows beside the value
1355    /// (DESIGN.md §5), which the resolved [`max_running`](Self::max_running)
1356    /// alone cannot tell apart from a cap that happens to equal the default.
1357    pub fn max_running_from_file(&self) -> Option<i64> {
1358        self.max_running
1359    }
1360
1361    /// `default_agent` as the file spells it, before the PATH probe that
1362    /// [`default_name`](Self::default_name) falls back to.
1363    pub fn default_agent_from_file(&self) -> Option<&str> {
1364        self.default.as_deref()
1365    }
1366
1367    /// `default_viewer` as the file spells it, before the resolution rules
1368    /// [`default_viewer_name`](Self::default_viewer_name) falls back to.
1369    pub fn default_viewer_from_file(&self) -> Option<&str> {
1370        self.default_viewer.as_deref()
1371    }
1372
1373    /// Every agent name defined in the config, for the TUI's dispatch picker
1374    /// (DESIGN.md §8/§9). `agents` is a `BTreeMap`, so this is already sorted.
1375    pub fn agent_names(&self) -> Vec<String> {
1376        self.agents.keys().cloned().collect()
1377    }
1378
1379    /// The verb templates of a named agent, if it is configured. Used where a
1380    /// session already records which agent ran it — jump-in, reconciliation —
1381    /// so no default/override resolution applies.
1382    pub fn agent(&self, name: &str) -> Option<&AgentTemplate> {
1383        self.agents.get(name)
1384    }
1385
1386    /// The agent for a task: its `agent` override if set, otherwise the
1387    /// resolved default (§8). An override or default naming an agent absent
1388    /// from the config is an error here, not a panic at spawn time.
1389    pub fn resolve(&self, task_override: Option<&str>) -> Result<ResolvedAgent> {
1390        self.resolve_with(task_override, &binary_on_path)
1391    }
1392
1393    /// [`resolve`](Self::resolve) with an injectable PATH probe, so the
1394    /// default-resolution path is testable without depending on what happens
1395    /// to be installed.
1396    fn resolve_with(
1397        &self,
1398        task_override: Option<&str>,
1399        probe: &dyn Fn(&str) -> bool,
1400    ) -> Result<ResolvedAgent> {
1401        let (name, origin) = match task_override {
1402            Some(name) => (name.to_string(), "task agent override"),
1403            None => (self.effective_default(probe)?, "config default"),
1404        };
1405        let agent = self.agents.get(&name).ok_or_else(|| Error::UnknownAgent {
1406            name: name.clone(),
1407            origin,
1408            path: self.path.clone(),
1409            known: self.agents.keys().cloned().collect::<Vec<_>>().join(", "),
1410        })?;
1411        Ok(ResolvedAgent {
1412            name,
1413            dispatch: agent.dispatch().to_string(),
1414            sessions: agent.sessions.clone(),
1415            attach: agent.attach.clone(),
1416            resume: agent.resume.clone(),
1417            message: agent.message.clone(),
1418            logs: agent.logs.clone(),
1419            cap: agent.cap.clone(),
1420            stop: agent.stop.clone(),
1421            plan: agent.plan.clone(),
1422            model: agent.model.clone(),
1423            model_deep: agent.model_deep.clone(),
1424            model_plan: agent.model_plan.clone(),
1425        })
1426    }
1427
1428    /// The default agent's name: the user's `default` when set (honoured even
1429    /// if it names a missing agent, so `resolve` reports the mismatch), else
1430    /// the first built-in found on PATH. Errors with guidance when neither
1431    /// yields anything.
1432    fn effective_default(&self, probe: &dyn Fn(&str) -> bool) -> Result<String> {
1433        if let Some(default) = &self.default {
1434            return Ok(default.clone());
1435        }
1436        for candidate in DEFAULT_PROBE_ORDER {
1437            if self.agents.contains_key(candidate) && probe(candidate) {
1438                return Ok(candidate.to_string());
1439            }
1440        }
1441        Err(Error::NoDefaultAgent {
1442            probed: DEFAULT_PROBE_ORDER.join(", "),
1443            path: self.path.clone(),
1444        })
1445    }
1446
1447    /// The names of the user's `[viewers.*]` tables, sorted: the *editable*
1448    /// set, which is why the built-ins are not in it. Everything that offers a
1449    /// viewer to run — the TUI's viewer picker, `viewer list` — wants
1450    /// [`viewer_entries`](Self::viewer_entries) instead.
1451    pub fn viewer_names(&self) -> Vec<String> {
1452        self.viewers.keys().cloned().collect()
1453    }
1454
1455    /// Every effective viewer as `(name, cmd, provenance)`, sorted by name —
1456    /// the built-ins with the user's tables layered over them, mirroring the
1457    /// agents' [`entries`](Self::entries). What `viewer list`, the Config
1458    /// screen and the default/review-action pickers show, since a built-in is
1459    /// a legitimate thing to run, star, or pin a project to.
1460    pub fn viewer_entries(&self) -> Vec<(&str, &str, Provenance)> {
1461        let mut merged: BTreeMap<&str, (&str, Provenance)> = builtin_viewers()
1462            .iter()
1463            .map(|(name, viewer)| (name.as_str(), (viewer.cmd.as_str(), Provenance::BuiltIn)))
1464            .collect();
1465        for (name, viewer) in &self.viewers {
1466            let prov = if is_builtin_viewer(name) {
1467                Provenance::UserOverride
1468            } else {
1469                Provenance::User
1470            };
1471            merged.insert(name.as_str(), (viewer.cmd.as_str(), prov));
1472        }
1473        merged
1474            .into_iter()
1475            .map(|(name, (cmd, prov))| (name, cmd, prov))
1476            .collect()
1477    }
1478
1479    /// The anonymous `[viewer]` table's command, if the file defines one — the
1480    /// legacy default the Config screen surfaces read-only, since it carries no
1481    /// name to edit or delete by.
1482    pub fn anonymous_viewer_cmd(&self) -> Option<&str> {
1483        self.viewer.as_ref().map(|v| v.cmd.as_str())
1484    }
1485
1486    /// The name of the viewer `open` will run when nothing picks one by name,
1487    /// for `viewer list` and the Config screen to star: the user's
1488    /// `default_viewer` when set (honoured even if it names a missing viewer),
1489    /// else the sole `[viewers.*]` entry, else the first built-in found on
1490    /// PATH. The anonymous `[viewer]` table has no name, so it yields `None`
1491    /// here even though it resolves.
1492    pub fn default_viewer_name(&self) -> Option<String> {
1493        self.default_viewer_name_with(&binary_on_path)
1494    }
1495
1496    /// [`default_viewer_name`](Self::default_viewer_name) with an injectable
1497    /// PATH probe, so the built-in fallback is testable without depending on
1498    /// what happens to be installed.
1499    fn default_viewer_name_with(&self, probe: &dyn Fn(&str) -> bool) -> Option<String> {
1500        if self.default_viewer.is_some() {
1501            return self.default_viewer.clone();
1502        }
1503        if self.viewer.is_some() {
1504            return None;
1505        }
1506        if self.viewers.len() == 1 {
1507            return self.viewers.keys().next().cloned();
1508        }
1509        probed_builtin_viewer(probe).map(str::to_string)
1510    }
1511
1512    /// Resolve a viewer command (DESIGN.md §11a). User configuration always
1513    /// wins: with a name, the `[viewers.<name>]` table, falling back to the
1514    /// built-in of that name; without one, `default_viewer`, else the anonymous
1515    /// `[viewer]` table, else the sole `[viewers.*]` entry, else the first
1516    /// built-in viewer found on PATH. Errors carry what to install or run.
1517    pub fn viewer_cmd(&self, name: Option<&str>) -> Result<&str> {
1518        self.viewer_cmd_with(name, &binary_on_path)
1519    }
1520
1521    /// [`viewer_cmd`](Self::viewer_cmd) with an injectable PATH probe, so the
1522    /// resolution order is testable without depending on what happens to be
1523    /// installed.
1524    fn viewer_cmd_with(&self, name: Option<&str>, probe: &dyn Fn(&str) -> bool) -> Result<&str> {
1525        match name {
1526            Some(name) => self.named_viewer(name),
1527            None => match &self.default_viewer {
1528                Some(default) => self.named_viewer(default),
1529                None => self
1530                    .viewer
1531                    .as_ref()
1532                    .map(|v| v.cmd.as_str())
1533                    .or_else(|| match self.viewers.len() {
1534                        1 => self.viewers.values().next().map(|v| v.cmd.as_str()),
1535                        _ => None,
1536                    })
1537                    .or_else(|| {
1538                        probed_builtin_viewer(probe).and_then(|name| self.named_viewer(name).ok())
1539                    })
1540                    .ok_or_else(|| Error::NoViewer {
1541                        probed: BUILTIN_VIEWER_NAMES.join("/"),
1542                    }),
1543            },
1544        }
1545    }
1546
1547    /// A viewer by name: the user's table when it defines one, else the
1548    /// built-in of that name — the same wholesale override the agents get.
1549    fn named_viewer(&self, name: &str) -> Result<&str> {
1550        self.viewers
1551            .get(name)
1552            .or_else(|| builtin_viewers().get(name))
1553            .map(|v| v.cmd.as_str())
1554            .ok_or_else(|| Error::UnknownViewer {
1555                name: name.to_string(),
1556                known: self
1557                    .viewer_entries()
1558                    .iter()
1559                    .map(|(name, _, _)| *name)
1560                    .collect::<Vec<_>>()
1561                    .join(", "),
1562                path: self.path.clone(),
1563            })
1564    }
1565
1566    /// The name of the agent used when a task has no override, for the CLI's
1567    /// `agent list` to flag it. `None` when no `default` is set and no
1568    /// built-in is on PATH — the same condition `resolve` errors on.
1569    pub fn default_name(&self) -> Option<String> {
1570        self.effective_default(&binary_on_path).ok()
1571    }
1572
1573    /// The provenance of a named agent, if it is configured.
1574    pub fn provenance(&self, name: &str) -> Option<Provenance> {
1575        self.provenance.get(name).copied()
1576    }
1577
1578    /// For a user override of a built-in, the verbs the built-in defines that
1579    /// the override drops — the one case layering can't fix (§8), so
1580    /// `agent list` can warn that those verbs stopped working. Empty for
1581    /// built-in or purely-additive user agents.
1582    pub fn override_missing_verbs(&self, name: &str) -> Vec<&'static str> {
1583        if self.provenance.get(name) != Some(&Provenance::UserOverride) {
1584            return Vec::new();
1585        }
1586        let (Some(user), Some(builtin)) = (self.agents.get(name), builtin_agents().get(name))
1587        else {
1588            return Vec::new();
1589        };
1590        OPTIONAL_VERBS
1591            .iter()
1592            .filter(|(_, defined)| defined(builtin).is_some() && defined(user).is_none())
1593            .map(|(verb, _)| *verb)
1594            .collect()
1595    }
1596
1597    /// Every agent as `(name, template, provenance)`, sorted by name, for
1598    /// `agent list`.
1599    pub fn entries(&self) -> impl Iterator<Item = (&str, &AgentTemplate, Provenance)> {
1600        self.agents.iter().map(|(name, agent)| {
1601            let prov = self
1602                .provenance
1603                .get(name)
1604                .copied()
1605                .unwrap_or(Provenance::User);
1606            (name.as_str(), agent, prov)
1607        })
1608    }
1609
1610    /// Write the [`starter_config`] skeleton to `path`, creating parent
1611    /// directories. Refuses to overwrite an existing file so a hand-tuned
1612    /// config is never clobbered.
1613    pub fn write_starter(path: &Path) -> Result<()> {
1614        if path.exists() {
1615            return Err(Error::Invalid(format!(
1616                "{} already exists; edit it directly rather than reinitialising",
1617                path.display()
1618            )));
1619        }
1620        if let Some(parent) = path.parent() {
1621            std::fs::create_dir_all(parent).map_err(|e| Error::AgentConfigInvalid {
1622                path: path.to_path_buf(),
1623                message: e.to_string(),
1624            })?;
1625        }
1626        std::fs::write(path, starter_config()).map_err(|e| Error::AgentConfigInvalid {
1627            path: path.to_path_buf(),
1628            message: e.to_string(),
1629        })
1630    }
1631}
1632
1633/// One session from an agent's `sessions` command output: a JSON array of
1634/// objects, of which the fields below are read and everything else ignored.
1635/// `sessionId` (falling back to `id`) is the durable reference substituted
1636/// into `{session}`; `cwd` and `startedAt` (ms epoch) identify a fresh
1637/// dispatch's session among its siblings; `state` and `pid` together say
1638/// whether the session is still going ([`AgentSessionEntry::liveness`]).
1639#[derive(Debug, Clone, PartialEq, Eq)]
1640pub struct AgentSessionEntry {
1641    pub session_ref: String,
1642    pub short_id: Option<String>,
1643    pub cwd: Option<String>,
1644    pub started_at_ms: Option<i64>,
1645    pub state: Option<String>,
1646    /// The supervisor process behind this session, where the listing names one.
1647    /// Authoritative for liveness when present (DESIGN.md §8), since a listing
1648    /// keeps entries for sessions whose state it never retires.
1649    pub pid: Option<i64>,
1650}
1651
1652/// What a listing entry says about its session, as far as pure logic can tell
1653/// (DESIGN.md §8). [`AgentSessionEntry::liveness`] classifies; the `voro` crate
1654/// resolves [`SessionLiveness::WhileProcessLives`] with the process check, so
1655/// `voro-core` still never touches a process.
1656#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1657pub enum SessionLiveness {
1658    /// Still going, with nothing left to check.
1659    Live,
1660    /// Live exactly while this process exists.
1661    WhileProcessLives(i64),
1662    /// No longer running.
1663    Dead,
1664}
1665
1666impl AgentSessionEntry {
1667    /// Whether this entry is the session a stored reference points at — either
1668    /// id form matches, since a log-parsed fallback may record the short id.
1669    pub fn matches_ref(&self, session_ref: &str) -> bool {
1670        self.session_ref == session_ref || self.short_id.as_deref() == Some(session_ref)
1671    }
1672
1673    /// Classify this entry's liveness (DESIGN.md §8). A listing that keeps an
1674    /// entry forever is the case this exists for: an agent's own listing may
1675    /// leave a long-dead session sitting at `blocked` and never say `done`, so
1676    /// not-done cannot mean live. A named `pid` decides it — which keeps a
1677    /// genuinely stalled session, blocked on a permission prompt with its
1678    /// supervisor alive, reading as live and attachable. Failing a pid, only
1679    /// `working` claims the session is going; anything else, including a state
1680    /// this doesn't recognise or no state at all, reads as dead.
1681    pub fn liveness(&self) -> SessionLiveness {
1682        match (self.state.as_deref(), self.pid) {
1683            (Some("done"), _) => SessionLiveness::Dead,
1684            (_, Some(pid)) => SessionLiveness::WhileProcessLives(pid),
1685            (Some("working"), None) => SessionLiveness::Live,
1686            _ => SessionLiveness::Dead,
1687        }
1688    }
1689
1690    /// Whether this entry says its session's turn has *ended* — the narrow
1691    /// reading the rest rule acts on (DESIGN.md §8), which is not the same
1692    /// question as [`liveness`](Self::liveness). Only `done` answers yes.
1693    /// `blocked` is the case that makes the distinction load-bearing: it reads
1694    /// dead without a live pid, but it is also what a permission prompt and a
1695    /// supervisor mid-turn look like, and stopping either would cut a turn off
1696    /// mid-sentence. Every other state, and an entry with no state at all, is
1697    /// likewise not a hand-back.
1698    pub fn at_rest(&self) -> bool {
1699        self.state.as_deref() == Some("done")
1700    }
1701}
1702
1703/// Parse a `sessions` command's stdout. Entries without any id are skipped
1704/// rather than failing the whole listing; anything that is not a JSON array
1705/// is an error, so a misconfigured `sessions` verb surfaces rather than
1706/// reading as "no sessions".
1707pub fn parse_sessions_json(json: &str) -> Result<Vec<AgentSessionEntry>> {
1708    let value: serde_json::Value = serde_json::from_str(json)
1709        .map_err(|e| Error::Invalid(format!("sessions output is not JSON: {e}")))?;
1710    let array = value
1711        .as_array()
1712        .ok_or_else(|| Error::Invalid("sessions output is not a JSON array".into()))?;
1713    let mut entries = Vec::new();
1714    for item in array {
1715        let get_str = |key: &str| item.get(key).and_then(|v| v.as_str()).map(str::to_string);
1716        let Some(session_ref) = get_str("sessionId").or_else(|| get_str("id")) else {
1717            continue;
1718        };
1719        entries.push(AgentSessionEntry {
1720            session_ref,
1721            short_id: get_str("id"),
1722            cwd: get_str("cwd"),
1723            started_at_ms: item.get("startedAt").and_then(|v| v.as_i64()),
1724            state: get_str("state"),
1725            pid: item.get("pid").and_then(|v| v.as_i64()),
1726        });
1727    }
1728    Ok(entries)
1729}
1730
1731#[cfg(test)]
1732mod tests {
1733    use super::*;
1734
1735    const CONFIG: &str = r#"
1736        default_agent = "claude"
1737
1738        [agents.claude]
1739        cmd = "claude -p --output-format stream-json {prompt_file}"
1740
1741        [agents.codex]
1742        cmd = "codex exec {prompt_file}"
1743    "#;
1744
1745    fn config() -> AgentsConfig {
1746        AgentsConfig::parse(CONFIG, Path::new("/tmp/voro.toml")).unwrap()
1747    }
1748
1749    fn parse(text: &str) -> Result<AgentsConfig> {
1750        AgentsConfig::parse(text, Path::new("/tmp/voro.toml"))
1751    }
1752
1753    /// A PATH probe finding nothing, so a resolution test never depends on
1754    /// what the developer happens to have installed.
1755    fn none_installed(_: &str) -> bool {
1756        false
1757    }
1758
1759    /// A PATH probe finding exactly one binary.
1760    fn only(installed: &'static str) -> impl Fn(&str) -> bool {
1761        move |name| name == installed
1762    }
1763
1764    #[test]
1765    fn absent_costs_and_max_running_take_the_defaults() {
1766        // A file that says nothing about pricing prices the queue exactly as
1767        // the built-ins do (DESIGN.md §7) — as does a missing file.
1768        for config in [
1769            config(),
1770            AgentsConfig::builtin_only(Path::new("/tmp/voro.toml")),
1771        ] {
1772            assert_eq!(config.costs(), AttentionCosts::default());
1773            assert_eq!(config.max_running(), DEFAULT_MAX_RUNNING);
1774        }
1775    }
1776
1777    #[test]
1778    fn costs_table_overrides_only_the_actions_it_names() {
1779        let config = parse(
1780            r#"
1781            max_running = 3
1782
1783            [costs]
1784            review = 2.5
1785            do = 4.0
1786            "#,
1787        )
1788        .unwrap();
1789        let costs = config.costs();
1790        assert_eq!(costs.review, 2.5);
1791        assert_eq!(costs.human_do, 4.0);
1792        // untouched keys keep the defaults
1793        assert_eq!(costs.answer, AttentionCosts::default().answer);
1794        assert_eq!(costs.triage, AttentionCosts::default().triage);
1795        assert_eq!(costs.dispatch, AttentionCosts::default().dispatch);
1796        assert_eq!(config.max_running(), 3);
1797    }
1798
1799    #[test]
1800    fn a_non_positive_cost_is_refused() {
1801        // A zero or negative divisor would blow up or invert the ranking, so
1802        // it is caught at load rather than producing a nonsense queue.
1803        for text in [
1804            "[costs]\nreview = 0",
1805            "[costs]\nanswer = -1.0",
1806            "[costs]\ntriage = nan",
1807        ] {
1808            let e = parse(text).unwrap_err().to_string();
1809            assert!(e.contains("must be a positive number"), "{text}: {e}");
1810        }
1811    }
1812
1813    #[test]
1814    fn a_negative_max_running_is_refused() {
1815        let e = parse("max_running = -1").unwrap_err().to_string();
1816        assert!(e.contains("cannot be negative"), "{e}");
1817        // zero is legal — it is how the operator stops the queue offering
1818        // dispatches at all.
1819        assert_eq!(parse("max_running = 0").unwrap().max_running(), 0);
1820    }
1821
1822    #[test]
1823    fn an_unknown_cost_key_is_refused_rather_than_ignored() {
1824        let e = parse("[costs]\nredispatch = 1.0").unwrap_err().to_string();
1825        assert!(e.contains("redispatch"), "{e}");
1826    }
1827
1828    #[test]
1829    fn agent_names_lists_every_configured_agent() {
1830        assert_eq!(config().agent_names(), vec!["claude", "codex"]);
1831    }
1832
1833    #[test]
1834    fn resolves_default_when_task_has_no_override() {
1835        let resolved = config().resolve(None).unwrap();
1836        assert_eq!(resolved.name, "claude");
1837        assert_eq!(
1838            resolved.dispatch,
1839            "claude -p --output-format stream-json {prompt_file}"
1840        );
1841    }
1842
1843    #[test]
1844    fn task_override_wins_over_default() {
1845        let resolved = config().resolve(Some("codex")).unwrap();
1846        assert_eq!(resolved.name, "codex");
1847        assert_eq!(resolved.dispatch, "codex exec {prompt_file}");
1848    }
1849
1850    #[test]
1851    fn unknown_override_errors_at_resolution() {
1852        let err = config().resolve(Some("gemini")).unwrap_err();
1853        let message = err.to_string();
1854        assert!(message.contains("gemini"), "{message}");
1855        assert!(message.contains("task agent override"), "{message}");
1856        assert!(message.contains("claude, codex"), "{message}");
1857    }
1858
1859    #[test]
1860    fn unknown_default_errors_at_resolution() {
1861        let text = r#"
1862            default_agent = "gemini"
1863
1864            [agents.claude]
1865            cmd = "claude -p {prompt_file}"
1866        "#;
1867        let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
1868        let message = config.resolve(None).unwrap_err().to_string();
1869        assert!(message.contains("gemini"), "{message}");
1870        assert!(message.contains("config default"), "{message}");
1871    }
1872
1873    #[test]
1874    fn cmd_without_prompt_file_placeholder_is_rejected() {
1875        let text = r#"
1876            default_agent = "claude"
1877
1878            [agents.claude]
1879            cmd = "claude -p"
1880        "#;
1881        let message = AgentsConfig::parse(text, Path::new("/tmp/voro.toml"))
1882            .unwrap_err()
1883            .to_string();
1884        assert!(message.contains("{prompt_file}"), "{message}");
1885        assert!(message.contains("claude"), "{message}");
1886    }
1887
1888    #[test]
1889    fn invalid_toml_names_the_file() {
1890        let message = AgentsConfig::parse("default = ", Path::new("/tmp/voro.toml"))
1891            .unwrap_err()
1892            .to_string();
1893        assert!(message.contains("/tmp/voro.toml"), "{message}");
1894    }
1895
1896    #[test]
1897    fn loads_from_disk() {
1898        let path = std::env::temp_dir().join(format!("voro-agents-{}.toml", std::process::id()));
1899        std::fs::write(&path, CONFIG).unwrap();
1900        let config = AgentsConfig::load(&path).unwrap();
1901        std::fs::remove_file(&path).unwrap();
1902        assert_eq!(config.resolve(None).unwrap().name, "claude");
1903    }
1904
1905    #[test]
1906    fn missing_file_loads_the_builtins() {
1907        let config = AgentsConfig::load(Path::new("/nonexistent/voro.toml")).unwrap();
1908        assert_eq!(config.agent_names(), vec!["claude", "codex"]);
1909        assert_eq!(config.provenance("claude"), Some(Provenance::BuiltIn));
1910        let claude = config.agent("claude").unwrap();
1911        assert!(claude.dispatch().contains("--bg"), "{}", claude.dispatch());
1912        assert!(claude.sessions().is_some());
1913        assert!(claude.attach().is_some());
1914        assert!(claude.resume().is_some());
1915    }
1916
1917    #[test]
1918    fn builtins_layer_under_a_user_file() {
1919        let text = r#"
1920            [agents.mycustom]
1921            dispatch = "mytool {prompt_file}"
1922        "#;
1923        let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
1924        assert_eq!(config.agent_names(), vec!["claude", "codex", "mycustom"]);
1925        assert_eq!(config.provenance("claude"), Some(Provenance::BuiltIn));
1926        assert_eq!(config.provenance("codex"), Some(Provenance::BuiltIn));
1927        assert_eq!(config.provenance("mycustom"), Some(Provenance::User));
1928        assert!(config.agent("claude").unwrap().sessions().is_some());
1929    }
1930
1931    #[test]
1932    fn a_user_table_overrides_a_builtin_wholesale() {
1933        let text = r#"
1934            [agents.claude]
1935            cmd = "claude -p {prompt_file}"
1936        "#;
1937        let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
1938        assert_eq!(config.provenance("claude"), Some(Provenance::UserOverride));
1939        let claude = config.agent("claude").unwrap();
1940        assert_eq!(claude.dispatch(), "claude -p {prompt_file}");
1941        assert_eq!(claude.sessions(), None, "override is not merged per-verb");
1942        assert_eq!(claude.attach(), None);
1943        assert_eq!(config.provenance("codex"), Some(Provenance::BuiltIn));
1944    }
1945
1946    #[test]
1947    fn override_dropping_verbs_is_reported() {
1948        let text = r#"
1949            [agents.claude]
1950            cmd = "claude -p {prompt_file}"
1951        "#;
1952        let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
1953        let missing = config.override_missing_verbs("claude");
1954        assert!(missing.contains(&"sessions"), "{missing:?}");
1955        assert!(missing.contains(&"attach"), "{missing:?}");
1956        assert!(missing.contains(&"resume"), "{missing:?}");
1957        assert!(config.override_missing_verbs("codex").is_empty());
1958    }
1959
1960    #[test]
1961    fn default_probes_path_when_the_user_sets_none() {
1962        let config = AgentsConfig::builtin_only(Path::new("/tmp/voro.toml"));
1963        let only_codex = |name: &str| name == "codex";
1964        assert_eq!(
1965            config.resolve_with(None, &only_codex).unwrap().name,
1966            "codex"
1967        );
1968        let both = |_: &str| true;
1969        assert_eq!(config.resolve_with(None, &both).unwrap().name, "claude");
1970    }
1971
1972    #[test]
1973    fn no_default_and_nothing_on_path_errors_with_guidance() {
1974        let config = AgentsConfig::builtin_only(Path::new("/tmp/voro.toml"));
1975        let none = |_: &str| false;
1976        let message = config.resolve_with(None, &none).unwrap_err().to_string();
1977        assert!(message.contains("no default agent"), "{message}");
1978        assert!(message.contains("claude, codex"), "{message}");
1979    }
1980
1981    #[test]
1982    fn user_default_is_honoured_over_the_path_probe() {
1983        let text = r#"
1984            default_agent = "codex"
1985        "#;
1986        let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
1987        let both = |_: &str| true;
1988        assert_eq!(config.resolve_with(None, &both).unwrap().name, "codex");
1989    }
1990
1991    // --- session verbs ---
1992
1993    const VERBS_CONFIG: &str = r#"
1994        default_agent = "claude"
1995
1996        [agents.claude]
1997        dispatch = "claude --bg \"$(cat {prompt_file})\""
1998        sessions = "claude agents --json"
1999        attach   = "claude attach {session}"
2000        resume   = "claude --resume {session}"
2001
2002        [agents.codex]
2003        dispatch = "codex exec {prompt_file}"
2004        resume   = "codex resume {session}"
2005    "#;
2006
2007    #[test]
2008    fn verbs_parse_and_resolve() {
2009        let config = AgentsConfig::parse(VERBS_CONFIG, Path::new("/tmp/voro.toml")).unwrap();
2010        let claude = config.resolve(None).unwrap();
2011        assert_eq!(claude.sessions.as_deref(), Some("claude agents --json"));
2012        assert_eq!(claude.attach.as_deref(), Some("claude attach {session}"));
2013        assert_eq!(claude.resume.as_deref(), Some("claude --resume {session}"));
2014
2015        let codex = config.resolve(Some("codex")).unwrap();
2016        assert_eq!(codex.sessions, None);
2017        assert_eq!(codex.attach, None);
2018        assert_eq!(codex.resume.as_deref(), Some("codex resume {session}"));
2019    }
2020
2021    #[test]
2022    fn cmd_alias_behaves_as_dispatch_with_every_verb_absent() {
2023        let resolved = config().resolve(None).unwrap();
2024        assert_eq!(
2025            resolved.dispatch,
2026            "claude -p --output-format stream-json {prompt_file}"
2027        );
2028        assert_eq!(resolved.sessions, None);
2029        assert_eq!(resolved.attach, None);
2030        assert_eq!(resolved.resume, None);
2031    }
2032
2033    #[test]
2034    fn both_dispatch_and_cmd_is_rejected() {
2035        let text = r#"
2036            default_agent = "claude"
2037
2038            [agents.claude]
2039            cmd = "claude -p {prompt_file}"
2040            dispatch = "claude --bg {prompt_file}"
2041        "#;
2042        let message = AgentsConfig::parse(text, Path::new("/tmp/voro.toml"))
2043            .unwrap_err()
2044            .to_string();
2045        assert!(message.contains("alias"), "{message}");
2046    }
2047
2048    #[test]
2049    fn agent_without_dispatch_or_cmd_is_rejected() {
2050        let text = r#"
2051            default_agent = "claude"
2052
2053            [agents.claude]
2054            sessions = "claude agents --json"
2055        "#;
2056        let message = AgentsConfig::parse(text, Path::new("/tmp/voro.toml"))
2057            .unwrap_err()
2058            .to_string();
2059        assert!(message.contains("dispatch"), "{message}");
2060    }
2061
2062    #[test]
2063    fn attach_and_resume_require_the_session_placeholder() {
2064        for verb in ["attach", "resume"] {
2065            let text = format!(
2066                "default_agent = \"a\"\n\n[agents.a]\ndispatch = \"run {{prompt_file}}\"\n\
2067                 {verb} = \"reopen {{prompt_file}}\"\n"
2068            );
2069            let message = AgentsConfig::parse(&text, Path::new("/tmp/voro.toml"))
2070                .unwrap_err()
2071                .to_string();
2072            assert!(message.contains("{session}"), "{verb}: {message}");
2073            assert!(message.contains(verb), "{verb}: {message}");
2074        }
2075    }
2076
2077    // --- plan verb ---
2078
2079    #[test]
2080    fn plan_parses_resolves_and_is_optional() {
2081        let text = r#"
2082            default_agent = "a"
2083
2084            [agents.a]
2085            dispatch = "run {prompt_file}"
2086            plan = "run --interactive {prompt_file}"
2087
2088            [agents.b]
2089            dispatch = "other {prompt_file}"
2090        "#;
2091        let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
2092        let a = config.resolve(None).unwrap();
2093        assert_eq!(a.plan.as_deref(), Some("run --interactive {prompt_file}"));
2094        assert_eq!(config.agent("a").unwrap().plan(), a.plan.as_deref());
2095        // an agent without the verb resolves with it absent, like the others
2096        let b = config.resolve(Some("b")).unwrap();
2097        assert_eq!(b.plan, None);
2098        assert_eq!(config.agent("b").unwrap().plan(), None);
2099    }
2100
2101    #[test]
2102    fn plan_requires_the_prompt_file_placeholder() {
2103        let text = r#"
2104            default_agent = "a"
2105
2106            [agents.a]
2107            dispatch = "run {prompt_file}"
2108            plan = "run --interactive"
2109        "#;
2110        let message = AgentsConfig::parse(text, Path::new("/tmp/voro.toml"))
2111            .unwrap_err()
2112            .to_string();
2113        assert!(message.contains("{prompt_file}"), "{message}");
2114        assert!(message.contains("plan"), "{message}");
2115    }
2116
2117    #[test]
2118    fn builtin_claude_defines_plan_and_an_override_dropping_it_is_reported() {
2119        let agents = builtin_agents();
2120        let plan = agents["claude"].plan().unwrap();
2121        assert!(plan.contains(PROMPT_FILE_PLACEHOLDER), "{plan}");
2122        assert!(
2123            !plan.contains("--bg"),
2124            "plan runs in the foreground: {plan}"
2125        );
2126        assert!(agents["codex"].plan().is_none());
2127
2128        let text = r#"
2129            [agents.claude]
2130            cmd = "claude -p {prompt_file}"
2131        "#;
2132        let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
2133        assert!(
2134            config.override_missing_verbs("claude").contains(&"plan"),
2135            "{:?}",
2136            config.override_missing_verbs("claude")
2137        );
2138    }
2139
2140    // --- message verb ---
2141
2142    #[test]
2143    fn message_parses_resolves_and_is_optional() {
2144        let text = r#"
2145            default_agent = "a"
2146
2147            [agents.a]
2148            dispatch = "run {prompt_file}"
2149            message = "say --into {session} {prompt_file}"
2150
2151            [agents.b]
2152            dispatch = "other {prompt_file}"
2153        "#;
2154        let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
2155        let a = config.resolve(None).unwrap();
2156        assert_eq!(
2157            a.message.as_deref(),
2158            Some("say --into {session} {prompt_file}")
2159        );
2160        assert_eq!(config.agent("a").unwrap().message(), a.message.as_deref());
2161        let b = config.resolve(Some("b")).unwrap();
2162        assert_eq!(b.message, None);
2163        assert_eq!(config.agent("b").unwrap().message(), None);
2164    }
2165
2166    /// A message says something *into a session*, so it needs both halves:
2167    /// which session, and what to say.
2168    #[test]
2169    fn message_requires_both_the_session_and_prompt_file_placeholders() {
2170        for (template, missing) in [
2171            ("say {prompt_file}", SESSION_PLACEHOLDER),
2172            ("say --into {session}", PROMPT_FILE_PLACEHOLDER),
2173        ] {
2174            let text = format!(
2175                "default_agent = \"a\"\n\n[agents.a]\ndispatch = \"run {{prompt_file}}\"\n\
2176                 message = \"{template}\"\n"
2177            );
2178            let e = AgentsConfig::parse(&text, Path::new("/tmp/voro.toml"))
2179                .unwrap_err()
2180                .to_string();
2181            assert!(e.contains(missing), "{template}: {e}");
2182            assert!(e.contains("message"), "{template}: {e}");
2183        }
2184    }
2185
2186    /// `message` acts on a session that already exists, so it takes the launch
2187    /// placeholders no more than `attach` and `resume` do.
2188    #[test]
2189    fn message_refuses_the_launch_placeholders() {
2190        for placeholder in [
2191            MODEL_PLACEHOLDER,
2192            SESSION_NAME_PLACEHOLDER,
2193            TASK_ID_PLACEHOLDER,
2194        ] {
2195            let text = format!(
2196                "default_agent = \"a\"\n\n[agents.a]\ndispatch = \"run {{prompt_file}}\"\n\
2197                 model = \"m\"\n\
2198                 message = \"say --into {{session}} --as {placeholder} {{prompt_file}}\"\n"
2199            );
2200            let e = AgentsConfig::parse(&text, Path::new("/tmp/voro.toml"))
2201                .unwrap_err()
2202                .to_string();
2203            assert!(e.contains(placeholder), "{placeholder}: {e}");
2204            assert!(e.contains("message"), "{placeholder}: {e}");
2205        }
2206    }
2207
2208    #[test]
2209    fn builtin_claude_defines_message_and_an_override_dropping_it_is_reported() {
2210        let agents = builtin_agents();
2211        let message = agents["claude"].message().unwrap();
2212        assert!(message.contains(SESSION_PLACEHOLDER), "{message}");
2213        assert!(message.contains(PROMPT_FILE_PLACEHOLDER), "{message}");
2214        assert!(
2215            message.contains("-p"),
2216            "message is headless, not a terminal round-trip: {message}"
2217        );
2218        // codex names no message verb — the graceful-degradation case the TUI
2219        // reports on its status line.
2220        assert!(agents["codex"].message().is_none());
2221
2222        let text = r#"
2223            [agents.claude]
2224            cmd = "claude -p {prompt_file}"
2225        "#;
2226        let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
2227        assert!(
2228            config.override_missing_verbs("claude").contains(&"message"),
2229            "{:?}",
2230            config.override_missing_verbs("claude")
2231        );
2232    }
2233
2234    /// The listing and the dropped-verb warning read the same roster, so an
2235    /// agent cannot be listed as lacking a verb the warning says it dropped.
2236    #[test]
2237    fn verbs_lists_every_optional_verb_and_marks_a_forking_message() {
2238        let agents = builtin_agents();
2239        // The built-in claude resumes in place, so its message is named plainly.
2240        assert_eq!(
2241            agents["claude"].verbs(),
2242            vec![
2243                "sessions", "attach", "resume", "message", "logs", "cap", "stop", "plan"
2244            ]
2245        );
2246        assert_eq!(agents["codex"].verbs(), vec!["resume"]);
2247
2248        // A message that forks names the session it forks into, and the roster
2249        // says so — the marking outlives the built-in that used to carry it.
2250        let text = r#"
2251            [agents.a]
2252            dispatch = "run {prompt_file}"
2253            message = "say --into {session} --as {new_session} {prompt_file}"
2254        "#;
2255        let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
2256        assert_eq!(config.agent("a").unwrap().verbs(), vec!["message(fork)"]);
2257    }
2258
2259    /// Every verb the warning can name is a verb the listing can name, which is
2260    /// the invariant that kept the two lines disagreeing before they shared a
2261    /// roster: a wholesale override of claude that drops everything reports the
2262    /// same set the built-in row lists.
2263    #[test]
2264    fn the_listing_and_the_dropped_verb_warning_cover_the_same_verbs() {
2265        let text = r#"
2266            [agents.claude]
2267            cmd = "claude -p {prompt_file}"
2268        "#;
2269        let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
2270        let dropped = config.override_missing_verbs("claude");
2271        let listed: Vec<&str> = builtin_agents()["claude"]
2272            .verbs()
2273            .into_iter()
2274            .map(|verb| verb.split('(').next().expect("a verb name"))
2275            .collect();
2276        assert_eq!(dropped, listed);
2277        assert!(config.agent("claude").unwrap().verbs().is_empty());
2278    }
2279
2280    #[test]
2281    fn render_message_binds_both_placeholders_shell_quoted() {
2282        let rendered = render_message(
2283            "claude -p --resume {session} \"$(cat {prompt_file})\"",
2284            "3f6c-1111",
2285            Path::new("/run/msg-1.md"),
2286        );
2287        assert_eq!(
2288            rendered.command,
2289            "claude -p --resume '3f6c-1111' \"$(cat '/run/msg-1.md')\""
2290        );
2291        // A template that resumes in place keeps the reference it was given.
2292        assert_eq!(rendered.new_session_ref, None);
2293    }
2294
2295    /// A `message` template that forks names the session it forks into, and
2296    /// Voro supplies that name: a fresh v4 UUID, shell-quoted like the rest,
2297    /// handed back so the session row can follow the fork (DESIGN.md §8).
2298    #[test]
2299    fn render_message_binds_a_fresh_reference_for_a_forking_verb() {
2300        let rendered = render_message(
2301            "claude -p --resume {session} --fork-session --session-id {new_session} \
2302             \"$(cat {prompt_file})\"",
2303            "3f6c-1111",
2304            Path::new("/run/msg-1.md"),
2305        );
2306        let new_ref = rendered.new_session_ref.expect("a fresh reference");
2307        assert_ne!(new_ref, "3f6c-1111");
2308        assert_eq!(new_ref.len(), 36, "a v4 uuid: {new_ref}");
2309        assert!(
2310            rendered
2311                .command
2312                .contains(&format!("--session-id '{new_ref}'")),
2313            "{}",
2314            rendered.command
2315        );
2316        // and a second send forks somewhere else again
2317        let again = render_message(
2318            "claude --session-id {new_session} --resume {session} {prompt_file}",
2319            "3f6c-1111",
2320            Path::new("/run/msg-2.md"),
2321        );
2322        assert_ne!(again.new_session_ref, Some(new_ref));
2323    }
2324
2325    /// The permission mode belongs to a launch rather than to a verb: every
2326    /// built-in `claude` template that hands an agent a prompt to act on
2327    /// carries it, headless or not. `resume` carries none because it carries no
2328    /// prompt either — it reopens a session for the operator and starts no work
2329    /// of its own, so the ask-mode default is answerable by the person sitting
2330    /// in front of it.
2331    #[test]
2332    fn every_builtin_claude_launch_that_prompts_carries_a_permission_mode() {
2333        let claude = &builtin_agents()["claude"];
2334        for (verb, template) in [
2335            ("dispatch", claude.dispatch()),
2336            ("message", claude.message().expect("claude defines message")),
2337            ("plan", claude.plan().expect("claude defines plan")),
2338        ] {
2339            assert!(
2340                template.contains(PROMPT_FILE_PLACEHOLDER),
2341                "{verb} is meant to be a prompted launch: {template}"
2342            );
2343            assert!(
2344                template.contains("--permission-mode auto"),
2345                "{verb} asks an agent to act, so it cannot stop for an approval \
2346                 nobody is there to give: {template}"
2347            );
2348        }
2349        let resume = claude.resume().expect("claude defines resume");
2350        assert!(!resume.contains(PROMPT_FILE_PLACEHOLDER), "{resume}");
2351        assert!(!resume.contains("--permission-mode"), "{resume}");
2352    }
2353
2354    /// The placeholder is bound only where a send happens; on any other verb it
2355    /// would reach the shell as literal braces, so it is refused at load.
2356    #[test]
2357    fn new_session_is_refused_outside_the_message_verb() {
2358        for (verb, template) in [
2359            ("attach", "join {session} {new_session}"),
2360            ("resume", "reopen {session} {new_session}"),
2361            ("logs", "tail {session} {new_session}"),
2362            ("plan", "plan --session-id {new_session} {prompt_file}"),
2363        ] {
2364            let text = format!(
2365                "[agents.a]\ndispatch = \"run {{prompt_file}}\"\n{verb} = \"{template}\"\n"
2366            );
2367            let e = parse(&text).unwrap_err().to_string();
2368            assert!(e.contains("{new_session}"), "{verb}: {e}");
2369            assert!(e.contains(verb), "{verb}: {e}");
2370        }
2371        // and on the dispatch template itself, which starts a session rather
2372        // than joining one
2373        let e = parse("[agents.a]\ndispatch = \"run --session-id {new_session} {prompt_file}\"\n")
2374            .unwrap_err()
2375            .to_string();
2376        assert!(e.contains("dispatch carries {new_session}"), "{e}");
2377    }
2378
2379    /// The built-in `claude` message verb resumes in place (DESIGN.md §8): the
2380    /// supervisor that refuses a headless resume is released before the send is
2381    /// made, so the send addresses the session's own reference and the
2382    /// conversation stays under the name Voro composed for it.
2383    #[test]
2384    fn the_builtin_claude_message_verb_resumes_in_place() {
2385        let message = builtin_agents()["claude"].message().unwrap();
2386        assert!(!message.contains("--fork-session"), "{message}");
2387        assert!(!message.contains(NEW_SESSION_PLACEHOLDER), "{message}");
2388        assert!(message.contains("-p --resume {session}"), "{message}");
2389        // The session it resumes is the one it was given, so nothing downstream
2390        // has a new reference to record.
2391        let rendered = render_message(message, "uuid-1", Path::new("/tmp/p.txt"));
2392        assert_eq!(rendered.new_session_ref, None);
2393        assert!(
2394            rendered.command.contains("--resume 'uuid-1'"),
2395            "{rendered:?}"
2396        );
2397    }
2398
2399    #[test]
2400    fn render_session_binds_the_reference_shell_quoted() {
2401        assert_eq!(
2402            render_session("claude logs \"$(printf %.8s {session})\"", "3f6c-1111"),
2403            "claude logs \"$(printf %.8s '3f6c-1111')\""
2404        );
2405    }
2406
2407    /// The built-in `claude` defines `logs` and `codex` does not, which is the
2408    /// per-verb degradation the whole verb set is built on: cap badging is a
2409    /// claude capability, and codex dispatches exactly as before without one.
2410    #[test]
2411    fn only_the_claude_builtin_defines_logs() {
2412        let config = AgentsConfig::load(Path::new("/nonexistent/voro.toml")).unwrap();
2413        let claude = config.agent("claude").expect("the built-in claude");
2414        assert!(claude.logs().expect("a logs verb").contains("claude logs"));
2415        assert!(config.agent("codex").expect("codex").logs().is_none());
2416    }
2417
2418    /// `logs` joins the session verbs on both rules: it must name the session
2419    /// it reads, and it may not carry a launch placeholder that only `dispatch`
2420    /// and `plan` can resolve.
2421    #[test]
2422    fn logs_is_validated_as_a_session_verb() {
2423        for (logs, expected) in [
2424            ("agent-logs --tail", SESSION_PLACEHOLDER),
2425            ("agent-logs {session} --model {model}", MODEL_PLACEHOLDER),
2426            ("agent-logs {session} --task {task_id}", TASK_ID_PLACEHOLDER),
2427            (
2428                "agent-logs {session} --name {session_name}",
2429                SESSION_NAME_PLACEHOLDER,
2430            ),
2431        ] {
2432            let toml = format!(
2433                "[agents.a]\ndispatch = \"run {{prompt_file}}\"\nmodel = \"m\"\nlogs = \"{logs}\"\n"
2434            );
2435            let raw: RawConfig = toml::from_str(&toml).unwrap();
2436            let err = validate_agent("a", &raw.agents["a"], Path::new("/c.toml"))
2437                .expect_err("{logs} is refused");
2438            let message = err.to_string();
2439            assert!(message.contains("logs"), "{message}");
2440            assert!(message.contains(expected), "{message}");
2441        }
2442    }
2443
2444    /// The built-in `claude` defines `cap` and `codex` does not, and the
2445    /// spelling carries the three halves Voro depends on: it asks on the
2446    /// session's own model, it keeps only the *epoch* out of a rejection so an
2447    /// account merely spending its window prints nothing, and it bounds itself.
2448    #[test]
2449    fn only_the_claude_builtin_defines_cap() {
2450        let config = AgentsConfig::load(Path::new("/nonexistent/voro.toml")).unwrap();
2451        let cap = config
2452            .agent("claude")
2453            .expect("the built-in claude")
2454            .cap()
2455            .expect("a cap verb");
2456        assert!(cap.contains("rejected"), "{cap}");
2457        assert!(cap.contains("resetsAt"), "{cap}");
2458        assert!(cap.contains(MODEL_PLACEHOLDER), "{cap}");
2459        assert!(cap.contains("timeout"), "{cap}");
2460        assert!(config.agent("codex").expect("codex").cap().is_none());
2461    }
2462
2463    /// `cap` may name the model whose window it is asking about, and nothing
2464    /// else. It reads the account rather than a session, so `{session}` has
2465    /// nothing to name, and it is not a launch, so the launch placeholders have
2466    /// nothing to bind either.
2467    #[test]
2468    fn cap_names_the_model_and_nothing_else() {
2469        for placeholder in [
2470            SESSION_PLACEHOLDER,
2471            PROMPT_FILE_PLACEHOLDER,
2472            SESSION_NAME_PLACEHOLDER,
2473            TASK_ID_PLACEHOLDER,
2474            NEW_SESSION_PLACEHOLDER,
2475        ] {
2476            let toml = format!(
2477                "[agents.a]\ndispatch = \"run {{prompt_file}}\"\nmodel = \"m\"\n\
2478                 cap = \"agent-cap {placeholder}\"\n"
2479            );
2480            let raw: RawConfig = toml::from_str(&toml).unwrap();
2481            let err = validate_agent("a", &raw.agents["a"], Path::new("/c.toml"))
2482                .expect_err("a placeholder on cap is refused");
2483            let message = err.to_string();
2484            assert!(message.contains("cap"), "{message}");
2485            assert!(message.contains(placeholder), "{message}");
2486        }
2487        // The model is the exception, and a template naming nothing is valid
2488        // too: that agent is asked once rather than once per model.
2489        for cap in ["agent-cap --model {model}", "agent-cap --epoch"] {
2490            let toml = format!(
2491                "[agents.a]\ndispatch = \"run {{prompt_file}}\"\nmodel = \"m\"\ncap = \"{cap}\"\n"
2492            );
2493            let raw: RawConfig = toml::from_str(&toml).unwrap();
2494            validate_agent("a", &raw.agents["a"], Path::new("/c.toml")).expect("a valid cap");
2495        }
2496        // `{model}` with nothing to resolve it to is refused, as on a launch.
2497        let toml = "[agents.a]\ndispatch = \"run {prompt_file}\"\ncap = \"agent-cap {model}\"\n";
2498        let raw: RawConfig = toml::from_str(toml).unwrap();
2499        let err = validate_agent("a", &raw.agents["a"], Path::new("/c.toml"))
2500            .expect_err("{model} with no model key is refused");
2501        assert!(err.to_string().contains("sets no model"), "{err}");
2502    }
2503
2504    /// The model a `cap` reading asks about is the one its session launched
2505    /// with, resolved by the same rule the launch used — a deep task's session
2506    /// runs the deeper model, so the window that holds it is that model's.
2507    #[test]
2508    fn a_cap_asks_on_the_model_its_session_ran() {
2509        let text = "[agents.a]\ndispatch = \"run {prompt_file} --model {model}\"\n\
2510                    cap = \"agent-cap --model {model}\"\nmodel = \"workhorse\"\n\
2511                    model_deep = \"strongest\"\n";
2512        let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
2513        let agent = config.agent("a").expect("agent a");
2514        assert_eq!(agent.model_for(false), Some("workhorse"));
2515        assert_eq!(agent.model_for(true), Some("strongest"));
2516        assert_eq!(
2517            render_cap(agent.cap().unwrap(), agent.model_for(true)),
2518            "agent-cap --model strongest"
2519        );
2520        // An agent naming no deeper model runs the workhorse at either depth,
2521        // and one naming no model at all renders the template unchanged.
2522        assert_eq!(model_for_depth(Some("only"), None, true), Some("only"));
2523        assert_eq!(render_cap("agent-cap --epoch", None), "agent-cap --epoch");
2524    }
2525
2526    /// The built-in `stop` renders through the same session binder `logs` does,
2527    /// down to the truncation: `claude stop` keys on the eight-character job id,
2528    /// so the reference goes in shell-quoted inside the `printf` that trims it
2529    /// rather than whole.
2530    #[test]
2531    fn the_claude_stop_verb_renders_the_short_id() {
2532        let config = AgentsConfig::load(Path::new("/nonexistent/voro.toml")).unwrap();
2533        let stop = config
2534            .agent("claude")
2535            .expect("the built-in claude")
2536            .stop()
2537            .expect("a stop verb");
2538        assert_eq!(
2539            render_session(stop, "3f6c1111-2222-3333-4444-555555555555"),
2540            "claude stop \"$(printf %.8s '3f6c1111-2222-3333-4444-555555555555')\""
2541        );
2542    }
2543
2544    /// The degradation the verb rides on: `codex` names no `stop`, so a close
2545    /// under it retires nothing and leaves exactly the behaviour Voro had.
2546    #[test]
2547    fn the_codex_builtin_defines_no_stop() {
2548        let config = AgentsConfig::load(Path::new("/nonexistent/voro.toml")).unwrap();
2549        assert!(config.agent("codex").expect("codex").stop().is_none());
2550    }
2551
2552    /// `stop` joins the session verbs on both rules: it must name the session it
2553    /// retires, and it may not carry a launch placeholder only `dispatch` and
2554    /// `plan` can resolve.
2555    #[test]
2556    fn stop_is_validated_as_a_session_verb() {
2557        for (stop, expected) in [
2558            ("agent-stop --all", SESSION_PLACEHOLDER),
2559            ("agent-stop {session} --model {model}", MODEL_PLACEHOLDER),
2560            ("agent-stop {session} --task {task_id}", TASK_ID_PLACEHOLDER),
2561            (
2562                "agent-stop {session} --name {session_name}",
2563                SESSION_NAME_PLACEHOLDER,
2564            ),
2565            (
2566                "agent-stop {session} --into {new_session}",
2567                NEW_SESSION_PLACEHOLDER,
2568            ),
2569        ] {
2570            let toml = format!(
2571                "[agents.a]\ndispatch = \"run {{prompt_file}}\"\nmodel = \"m\"\nstop = \"{stop}\"\n"
2572            );
2573            let raw: RawConfig = toml::from_str(&toml).unwrap();
2574            let err = validate_agent("a", &raw.agents["a"], Path::new("/c.toml"))
2575                .expect_err("{stop} is refused");
2576            let message = err.to_string();
2577            assert!(message.contains("stop"), "{message}");
2578            assert!(message.contains(expected), "{message}");
2579        }
2580    }
2581
2582    /// An override that drops `stop` is reported like any other dropped verb, so
2583    /// `agent list` can say the sessions it closes will now linger in the
2584    /// agent's own listing.
2585    #[test]
2586    fn an_override_dropping_stop_is_reported() {
2587        let config = AgentsConfig::parse(
2588            "[agents.claude]\ndispatch = \"claude {prompt_file}\"\n",
2589            Path::new("/c.toml"),
2590        )
2591        .unwrap();
2592        assert!(config.override_missing_verbs("claude").contains(&"stop"));
2593    }
2594
2595    /// The one-pass rule (§8): a value carrying its own braces reaches the
2596    /// command line as written rather than being re-scanned.
2597    #[test]
2598    fn render_message_does_not_rescan_a_bound_value() {
2599        let rendered = render_message(
2600            "say {session} {prompt_file}",
2601            "{prompt_file}",
2602            Path::new("/run/m.md"),
2603        );
2604        assert_eq!(rendered.command, "say '{prompt_file}' '/run/m.md'");
2605    }
2606
2607    /// A `continue` line is an unknown field, so a config carrying one is
2608    /// refused rather than loading with a verb Voro never runs (DESIGN.md
2609    /// §6/§8).
2610    #[test]
2611    fn a_continue_verb_is_now_an_unknown_field() {
2612        let text = r#"
2613            default_agent = "a"
2614
2615            [agents.a]
2616            dispatch = "run {prompt_file}"
2617            continue = "reopen {session} {prompt_file}"
2618        "#;
2619        assert!(AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).is_err());
2620    }
2621
2622    #[test]
2623    fn agent_looks_up_templates_by_name() {
2624        let config = AgentsConfig::parse(VERBS_CONFIG, Path::new("/tmp/voro.toml")).unwrap();
2625        let claude = config.agent("claude").unwrap();
2626        assert_eq!(claude.attach(), Some("claude attach {session}"));
2627        assert_eq!(claude.sessions(), Some("claude agents --json"));
2628        assert!(config.agent("gemini").is_none());
2629    }
2630
2631    #[test]
2632    fn parse_sessions_json_reads_the_listing_shape() {
2633        let json = r#"[
2634            {"pid": 4321, "id": "deadbeef", "cwd": "/tmp/proj", "kind": "background",
2635             "startedAt": 1767950000000, "sessionId": "3f6c0e6e-1111-2222-3333-444455556666",
2636             "name": "t", "status": "idle", "state": "done"},
2637            {"id": "cafebabe", "cwd": "/tmp/other", "startedAt": 1767950001000},
2638            {"pid": 1}
2639        ]"#;
2640        let entries = parse_sessions_json(json).unwrap();
2641        assert_eq!(entries.len(), 2, "the id-less entry is skipped");
2642        assert_eq!(
2643            entries[0].session_ref,
2644            "3f6c0e6e-1111-2222-3333-444455556666"
2645        );
2646        assert_eq!(entries[0].short_id.as_deref(), Some("deadbeef"));
2647        assert_eq!(entries[0].cwd.as_deref(), Some("/tmp/proj"));
2648        assert_eq!(entries[0].started_at_ms, Some(1767950000000));
2649        assert_eq!(entries[0].pid, Some(4321));
2650        assert_eq!(entries[0].liveness(), SessionLiveness::Dead);
2651        assert!(entries[0].matches_ref("deadbeef"), "short id matches too");
2652        assert!(entries[0].matches_ref("3f6c0e6e-1111-2222-3333-444455556666"));
2653
2654        assert_eq!(entries[1].session_ref, "cafebabe", "id is the fallback");
2655        assert_eq!(entries[1].pid, None, "the field is optional");
2656        assert_eq!(
2657            entries[1].liveness(),
2658            SessionLiveness::Dead,
2659            "an entry saying neither state nor pid claims nothing, so it is not live"
2660        );
2661    }
2662
2663    /// The listing's own account of liveness (DESIGN.md §8). The case that
2664    /// forced it: an agent listing that never retires a finished session leaves
2665    /// it sitting at `blocked` indefinitely, so not-`done` cannot mean live —
2666    /// but a `blocked` entry whose supervisor pid is still there is a session
2667    /// genuinely stuck mid-turn, which must stay live and attachable.
2668    #[test]
2669    fn liveness_takes_done_first_then_pid_then_state() {
2670        let entry = |json: &str| {
2671            let listing = format!("[{{\"sessionId\": \"u\", {json}}}]");
2672            parse_sessions_json(&listing).unwrap().remove(0).liveness()
2673        };
2674        // `done` wins over a pid that is still around: the session is over
2675        // whatever process outlives it.
2676        assert_eq!(
2677            entry(r#""state": "done", "pid": 4321"#),
2678            SessionLiveness::Dead
2679        );
2680        assert_eq!(entry(r#""state": "done""#), SessionLiveness::Dead);
2681        // a pid decides every other state, including one Voro does not know
2682        for state in ["\"state\": \"blocked\", ", "\"state\": \"working\", ", ""] {
2683            assert_eq!(
2684                entry(&format!("{state}\"pid\": 4321")),
2685                SessionLiveness::WhileProcessLives(4321),
2686                "{state}"
2687            );
2688        }
2689        // without a pid, only `working` claims the session is going
2690        assert_eq!(entry(r#""state": "working""#), SessionLiveness::Live);
2691        assert_eq!(entry(r#""state": "blocked""#), SessionLiveness::Dead);
2692        assert_eq!(entry(r#""state": "idle""#), SessionLiveness::Dead);
2693    }
2694
2695    /// Rest is a narrower reading than death (DESIGN.md §8): the release acts
2696    /// on a turn that has *ended*, and only `done` says so. `blocked` is the
2697    /// separation that matters — dead to the liveness question, yet a turn still
2698    /// under way (a permission prompt, a supervisor mid-turn) that a stop would
2699    /// cut off.
2700    #[test]
2701    fn at_rest_is_done_alone() {
2702        let entry = |json: &str| {
2703            let listing = format!("[{{\"sessionId\": \"u\", {json}}}]");
2704            parse_sessions_json(&listing).unwrap().remove(0)
2705        };
2706        assert!(entry(r#""state": "done""#).at_rest());
2707        // a supervisor that outlives the turn does not make it unfinished
2708        assert!(entry(r#""state": "done", "pid": 4321"#).at_rest());
2709        for json in [
2710            r#""state": "blocked""#,
2711            r#""state": "blocked", "pid": 4321"#,
2712            r#""state": "working""#,
2713            r#""state": "idle""#,
2714            r#""state": "something-new""#,
2715            r#""pid": 4321"#,
2716            r#""cwd": "/tmp""#,
2717        ] {
2718            assert!(!entry(json).at_rest(), "{json}");
2719        }
2720    }
2721
2722    #[test]
2723    fn parse_sessions_json_rejects_non_arrays() {
2724        assert!(parse_sessions_json("{}").is_err());
2725        assert!(parse_sessions_json("not json").is_err());
2726        assert_eq!(parse_sessions_json("[]").unwrap(), vec![]);
2727    }
2728
2729    /// Nothing installed, nothing configured: the failure asks for the one
2730    /// thing the operator can act on — register the viewer they already use —
2731    /// and only then says what was probed. It never tells them to install an
2732    /// editor, and never calls the config file invalid: it may not even
2733    /// exist.
2734    #[test]
2735    fn viewer_resolution_errors_with_guidance_when_nothing_resolves() {
2736        let message = config()
2737            .viewer_cmd_with(None, &none_installed)
2738            .unwrap_err()
2739            .to_string();
2740        assert!(
2741            message.starts_with("no viewer set up — run `voro viewer add"),
2742            "{message}"
2743        );
2744        assert!(message.contains("'zed {path}'"), "{message}");
2745        // the probed built-ins are diagnosis, so they come after the action
2746        let (action, diagnosis) = message.split_once("; ").unwrap();
2747        assert!(diagnosis.contains("code/cursor/zed"), "{message}");
2748        assert!(!action.contains("install"), "{message}");
2749        assert!(!message.contains("invalid"), "{message}");
2750        assert!(config().viewer_names().is_empty());
2751        assert_eq!(config().default_viewer_name_with(&none_installed), None);
2752    }
2753
2754    /// The whole point of the built-in layer (DESIGN.md §11a): a config that
2755    /// defines no viewer at all still opens a task, given an editor on PATH.
2756    #[test]
2757    fn a_built_in_viewer_resolves_with_no_viewer_configured() {
2758        let config = config();
2759        assert_eq!(
2760            config.viewer_cmd_with(None, &only("zed")).unwrap(),
2761            "zed {path}"
2762        );
2763        assert_eq!(
2764            config.default_viewer_name_with(&only("zed")).as_deref(),
2765            Some("zed")
2766        );
2767        // probe order decides between two installed built-ins
2768        assert_eq!(
2769            config
2770                .viewer_cmd_with(None, &|name| matches!(name, "cursor" | "zed"))
2771                .unwrap(),
2772            "cursor -n {path}"
2773        );
2774        // and a built-in is resolvable by name whether or not it is installed,
2775        // which is what makes `default_viewer = "code"` work with no tables
2776        assert_eq!(config.viewer_cmd(Some("code")).unwrap(), "code -n {path}");
2777    }
2778
2779    /// User configuration always wins over the probe, in the documented order.
2780    #[test]
2781    fn user_viewers_outrank_the_probed_built_in() {
2782        let installed = |_: &str| true;
2783
2784        let sole = parse("[viewers.mine]\ncmd = \"mine {path}\"").unwrap();
2785        assert_eq!(
2786            sole.viewer_cmd_with(None, &installed).unwrap(),
2787            "mine {path}"
2788        );
2789        assert_eq!(
2790            sole.default_viewer_name_with(&installed).as_deref(),
2791            Some("mine")
2792        );
2793
2794        let anonymous = parse("[viewer]\ncmd = \"anon {path}\"").unwrap();
2795        assert_eq!(
2796            anonymous.viewer_cmd_with(None, &installed).unwrap(),
2797            "anon {path}"
2798        );
2799        // the anonymous table resolves but has no name to star
2800        assert_eq!(anonymous.default_viewer_name_with(&installed), None);
2801
2802        let named = parse(
2803            "default_viewer = \"mine\"\n[viewers.mine]\ncmd = \"mine {path}\"\n\
2804             [viewers.other]\ncmd = \"other {path}\"",
2805        )
2806        .unwrap();
2807        assert_eq!(
2808            named.viewer_cmd_with(None, &installed).unwrap(),
2809            "mine {path}"
2810        );
2811    }
2812
2813    /// A `[viewers.code]` table replaces the built-in wholesale, exactly as an
2814    /// `[agents.claude]` table does — same name, user command, and a provenance
2815    /// that says so.
2816    #[test]
2817    fn a_user_table_overrides_a_built_in_viewer_wholesale() {
2818        let config = parse("[viewers.code]\ncmd = \"code --wait {path}\"").unwrap();
2819        assert_eq!(
2820            config.viewer_cmd(Some("code")).unwrap(),
2821            "code --wait {path}"
2822        );
2823        // still found by the probe, still what the default resolves to
2824        assert_eq!(
2825            config.viewer_cmd_with(None, &only("code")).unwrap(),
2826            "code --wait {path}"
2827        );
2828        let entries = config.viewer_entries();
2829        let code = entries.iter().find(|(name, ..)| *name == "code").unwrap();
2830        assert_eq!(code.2, Provenance::UserOverride);
2831    }
2832
2833    #[test]
2834    fn viewer_entries_layer_the_built_ins_under_the_user_tables() {
2835        let config = parse("[viewers.mine]\ncmd = \"mine {path}\"").unwrap();
2836        let entries: Vec<(&str, Provenance)> = config
2837            .viewer_entries()
2838            .into_iter()
2839            .map(|(name, _, prov)| (name, prov))
2840            .collect();
2841        assert_eq!(
2842            entries,
2843            vec![
2844                ("code", Provenance::BuiltIn),
2845                ("cursor", Provenance::BuiltIn),
2846                ("mine", Provenance::User),
2847                ("zed", Provenance::BuiltIn),
2848            ]
2849        );
2850        // viewer_names stays the editable set
2851        assert_eq!(config.viewer_names(), vec!["mine"]);
2852    }
2853
2854    #[test]
2855    fn the_anonymous_viewer_table_is_the_default_viewer() {
2856        let text = r#"
2857            default_agent = "claude"
2858
2859            [agents.claude]
2860            cmd = "claude -p {prompt_file}"
2861
2862            [viewer]
2863            cmd = "zed {path}"
2864        "#;
2865        let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
2866        assert_eq!(config.viewer_cmd(None).unwrap(), "zed {path}");
2867    }
2868
2869    #[test]
2870    fn named_viewers_resolve_by_name_and_default_viewer_picks_among_them() {
2871        let text = r#"
2872            default_viewer = "zed"
2873
2874            [viewers.zed]
2875            cmd = "zed {path}"
2876
2877            [viewers.difftool]
2878            cmd = "git difftool -d"
2879        "#;
2880        let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
2881        assert_eq!(config.viewer_names(), vec!["difftool", "zed"]);
2882        assert_eq!(
2883            config.viewer_cmd(Some("difftool")).unwrap(),
2884            "git difftool -d"
2885        );
2886        assert_eq!(config.viewer_cmd(None).unwrap(), "zed {path}");
2887        assert_eq!(config.default_viewer_name().as_deref(), Some("zed"));
2888    }
2889
2890    #[test]
2891    fn a_sole_named_viewer_is_the_default_without_being_named() {
2892        let text = r#"
2893            [viewers.zed]
2894            cmd = "zed {path}"
2895        "#;
2896        let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
2897        assert_eq!(config.viewer_cmd(None).unwrap(), "zed {path}");
2898        assert_eq!(config.default_viewer_name().as_deref(), Some("zed"));
2899    }
2900
2901    /// Two viewers and no `default_viewer` names none of them, so resolution
2902    /// carries on to the built-in probe rather than stopping — and says what to
2903    /// install when that finds nothing either.
2904    #[test]
2905    fn several_named_viewers_without_a_default_fall_through_to_the_built_ins() {
2906        let text = r#"
2907            [viewers.mine]
2908            cmd = "mine {path}"
2909
2910            [viewers.difftool]
2911            cmd = "git difftool -d"
2912        "#;
2913        let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
2914        assert_eq!(
2915            config.viewer_cmd_with(None, &only("zed")).unwrap(),
2916            "zed {path}"
2917        );
2918        let message = config
2919            .viewer_cmd_with(None, &none_installed)
2920            .unwrap_err()
2921            .to_string();
2922        assert!(message.contains("no viewer set up"), "{message}");
2923    }
2924
2925    #[test]
2926    fn an_unknown_viewer_name_errors_listing_the_known_ones() {
2927        let text = r#"
2928            [viewers.zed]
2929            cmd = "zed {path}"
2930        "#;
2931        let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
2932        let message = config.viewer_cmd(Some("emacs")).unwrap_err().to_string();
2933        assert!(
2934            message.starts_with("no viewer named 'emacs' — run"),
2935            "{message}"
2936        );
2937        // the known set is every viewer that resolves, built-ins included
2938        assert!(message.contains("code, cursor, zed"), "{message}");
2939        assert!(!message.contains("invalid"), "{message}");
2940        // a default_viewer naming a missing table reports the same way
2941        let text = r#"default_viewer = "gone""#;
2942        let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
2943        let message = config.viewer_cmd(None).unwrap_err().to_string();
2944        assert!(message.contains("gone"), "{message}");
2945    }
2946
2947    #[test]
2948    fn starter_config_defines_nothing_and_leaves_the_builtins() {
2949        let config = AgentsConfig::parse(&starter_config(), Path::new("/tmp/voro.toml")).unwrap();
2950        assert_eq!(config.agent_names(), vec!["claude", "codex"]);
2951        assert_eq!(config.provenance("claude"), Some(Provenance::BuiltIn));
2952        assert!(config.viewer_names().is_empty());
2953        assert!(config.viewer_cmd_with(None, &none_installed).is_err());
2954        let claude = config.agent("claude").unwrap();
2955        assert!(claude.dispatch().contains("--bg"), "{}", claude.dispatch());
2956        assert!(
2957            claude.dispatch().contains(SESSION_NAME_PLACEHOLDER),
2958            "{}",
2959            claude.dispatch()
2960        );
2961        assert!(claude.sessions().is_some());
2962        assert!(claude.attach().is_some());
2963        assert!(claude.resume().is_some());
2964    }
2965
2966    #[test]
2967    fn starter_config_reproduces_the_builtins_commented_for_copying() {
2968        let skeleton = starter_config();
2969        for line in BUILTIN_AGENTS
2970            .lines()
2971            .chain(BUILTIN_VIEWERS.lines())
2972            .filter(|l| !l.is_empty())
2973        {
2974            let commented = format!("# {line}");
2975            assert!(
2976                skeleton.contains(&commented),
2977                "skeleton is missing built-in line: {commented}"
2978            );
2979        }
2980        // Uncommenting the reproduced claude block must yield a valid override.
2981        let uncommented: String = BUILTIN_AGENTS
2982            .lines()
2983            .take_while(|l| !l.starts_with("[agents.codex]"))
2984            .collect::<Vec<_>>()
2985            .join("\n");
2986        let config = AgentsConfig::parse(&uncommented, Path::new("/tmp/voro.toml")).unwrap();
2987        assert_eq!(config.provenance("claude"), Some(Provenance::UserOverride));
2988        assert!(config.override_missing_verbs("claude").is_empty());
2989    }
2990
2991    #[test]
2992    fn entries_carry_name_template_and_provenance() {
2993        // CONFIG overrides both built-ins wholesale, hence UserOverride below.
2994        let config = config();
2995        let entries: Vec<_> = config.entries().collect();
2996        assert_eq!(entries.len(), 2);
2997        assert_eq!(entries[0].0, "claude");
2998        assert_eq!(
2999            entries[0].1.dispatch(),
3000            "claude -p --output-format stream-json {prompt_file}"
3001        );
3002        assert_eq!(entries[0].2, Provenance::UserOverride);
3003        assert_eq!(entries[1].0, "codex");
3004        assert_eq!(entries[1].1.dispatch(), "codex exec {prompt_file}");
3005        assert_eq!(entries[1].2, Provenance::UserOverride);
3006    }
3007
3008    #[test]
3009    fn write_starter_creates_parent_and_refuses_to_clobber() {
3010        let dir = std::env::temp_dir().join(format!("voro-init-{}", std::process::id()));
3011        let path = dir.join("voro/voro.toml");
3012        let _ = std::fs::remove_dir_all(&dir);
3013
3014        AgentsConfig::write_starter(&path).unwrap();
3015        let config = AgentsConfig::load(&path).unwrap();
3016        assert_eq!(config.agent_names(), vec!["claude", "codex"]);
3017
3018        let err = AgentsConfig::write_starter(&path).unwrap_err().to_string();
3019        assert!(err.contains("already exists"), "{err}");
3020
3021        std::fs::remove_dir_all(&dir).unwrap();
3022    }
3023
3024    #[test]
3025    fn builtins_parse_and_validate() {
3026        let agents = builtin_agents();
3027        assert!(agents.contains_key("claude"));
3028        assert!(agents.contains_key("codex"));
3029        assert!(agents["claude"].sessions().is_some());
3030        assert!(agents["codex"].resume().is_some());
3031    }
3032
3033    // --- launch identity and rendered commands ---
3034
3035    /// A dispatch of task 7 with a fixed prompt file, so a rendered command is
3036    /// a stable string to assert on.
3037    fn spec(deep: bool) -> LaunchSpec<'static> {
3038        LaunchSpec {
3039            launch: Launch::Dispatch {
3040                task_id: 7,
3041                title: "Widen the strip".into(),
3042            },
3043            prompt_file: Path::new("/tmp/p.md"),
3044            deep,
3045        }
3046    }
3047
3048    #[test]
3049    fn a_launch_names_its_session_and_its_files() {
3050        let dispatch = Launch::Dispatch {
3051            task_id: 42,
3052            title: "Widen the strip".into(),
3053        };
3054        let refine = Launch::Refine { task_id: 42 };
3055        let plan = Launch::Plan {
3056            project: "mote".into(),
3057        };
3058        // The dispatch name is the published contract; anything else pointed at
3059        // the same task suffixes a kind rather than colliding with it. A
3060        // planning session names its project, so the bare number in a session
3061        // name is always a task id.
3062        assert_eq!(dispatch.session_name(), "voro-42-widen-the-strip");
3063        assert_eq!(refine.session_name(), "voro-42-refine");
3064        assert_eq!(plan.session_name(), "voro-plan-mote");
3065        assert_ne!(dispatch.session_name(), refine.session_name());
3066        // The file slugs are exactly what the three paths computed before the
3067        // identity was factored out, so no prompt or log filename moved.
3068        assert_eq!(dispatch.slug(), "task-42");
3069        assert_eq!(refine.slug(), "refine-42");
3070        assert_eq!(plan.slug(), "plan-mote");
3071        assert_eq!(dispatch.task_id(), Some(42));
3072        assert_eq!(refine.task_id(), Some(42));
3073        assert_eq!(plan.task_id(), None);
3074    }
3075
3076    #[test]
3077    fn a_quick_propose_names_its_project_and_has_no_task() {
3078        // Like a planning session it is drafting a task rather than naming one,
3079        // so it carries no task id and names its project the way `N`'s session
3080        // does — leaving a bare number in a session name always a task id.
3081        let propose = Launch::Propose {
3082            project: "mote".into(),
3083        };
3084        assert_eq!(propose.session_name(), "voro-propose-mote");
3085        assert_eq!(propose.slug(), "propose-mote");
3086        assert_eq!(propose.task_id(), None);
3087        assert_ne!(
3088            propose.session_name(),
3089            Launch::Dispatch {
3090                task_id: 2,
3091                title: "Propose".into(),
3092            }
3093            .session_name()
3094        );
3095    }
3096
3097    /// A dispatch says what it is working on, because the name is all the
3098    /// operator gets in the agents view, the `/resume` picker and the phone's
3099    /// session list.
3100    #[test]
3101    fn a_dispatch_names_its_session_for_the_task_title() {
3102        let named = |id: i64, title: &str| {
3103            Launch::Dispatch {
3104                task_id: id,
3105                title: title.into(),
3106            }
3107            .session_name()
3108        };
3109        assert_eq!(
3110            named(428, "Deliver quick messages"),
3111            "voro-428-deliver-quick-messages"
3112        );
3113        // Whole words only: the budget stops the name before the word that
3114        // would overrun it rather than cutting that word in half.
3115        assert_eq!(
3116            named(1, "Make the score decomposition legible"),
3117            "voro-1-make-the-score"
3118        );
3119        // At least one word, even where that word alone is over budget: a name
3120        // cut mid-word reads as a different task.
3121        assert_eq!(
3122            named(2, "Internationalisation everywhere"),
3123            "voro-2-internationalisation"
3124        );
3125        // Every name still starts `voro-<id>`, so prefix reading is untouched.
3126        for name in [named(7, "Anything at all"), named(7, "")] {
3127            assert!(name.starts_with("voro-7"), "{name}");
3128        }
3129    }
3130
3131    #[test]
3132    fn a_dispatch_slug_survives_punctuation_and_unsanitizable_titles() {
3133        let named = |title: &str| {
3134            Launch::Dispatch {
3135                task_id: 9,
3136                title: title.into(),
3137            }
3138            .session_name()
3139        };
3140        // The name reaches a shell command line, so nothing outside
3141        // `[A-Za-z0-9._-]` may survive — and the dashes punctuation leaves
3142        // behind are collapsed rather than kept.
3143        assert_eq!(named("It's \"fine\"; rm -rf /"), "voro-9-it-s-fine-rm-rf");
3144        assert_eq!(named("Fix voro-core_v1.2"), "voro-9-fix-voro-core_v1.2");
3145        // Case is dropped, unlike a project name: a title is a sentence.
3146        assert_eq!(named("ODM handoff"), "voro-9-odm-handoff");
3147        // A title that sanitizes to nothing leaves the bare name rather than a
3148        // row of dashes.
3149        assert_eq!(named(""), "voro-9");
3150        assert_eq!(named("   "), "voro-9");
3151        assert_eq!(named("!!! ???"), "voro-9");
3152        assert_eq!(named("日本語"), "voro-9");
3153    }
3154
3155    /// The one name a dispatch may not take: its own task's refine session.
3156    #[test]
3157    fn a_dispatch_cannot_slug_onto_a_kind_suffix() {
3158        let named = |title: &str| {
3159            Launch::Dispatch {
3160                task_id: 42,
3161                title: title.into(),
3162            }
3163            .session_name()
3164        };
3165        let refine = Launch::Refine { task_id: 42 }.session_name();
3166        // A second word takes the slug clear of the suffix on its own.
3167        assert_eq!(named("Refine the rewrite"), "voro-42-refine-the-rewrite");
3168        // Where the second word is over budget the guard takes it anyway,
3169        // since overrunning beats colliding.
3170        assert_eq!(
3171            named("Refine internationalisation"),
3172            "voro-42-refine-internationalisation"
3173        );
3174        assert_eq!(
3175            named("Refine"),
3176            "voro-42",
3177            "a one-word title has no next word to extend with"
3178        );
3179        for title in ["Refine the rewrite", "Refine", "refine!", "  refine  "] {
3180            assert_ne!(named(title), refine, "collided on {title}");
3181        }
3182    }
3183
3184    #[test]
3185    fn a_task_less_launch_sanitizes_its_project_name() {
3186        // The session name is substituted into a shell command line and the
3187        // slug becomes a filename, so nothing outside `[A-Za-z0-9._-]` may
3188        // survive either. Case does, so a project named in capitals reads as
3189        // itself.
3190        let plan = |name: &str| Launch::Plan {
3191            project: name.to_string(),
3192        };
3193        assert_eq!(plan("odm 2").session_name(), "voro-plan-odm-2");
3194        assert_eq!(plan("odm 2").slug(), "plan-odm-2");
3195        assert_eq!(plan("ODM").session_name(), "voro-plan-ODM");
3196        assert_eq!(
3197            plan("it's \"fine\"; rm -rf /").session_name(),
3198            "voro-plan-it-s--fine---rm--rf--"
3199        );
3200        assert_eq!(plan("a/b").slug(), "plan-a-b");
3201        // The characters a name may keep pass through untouched.
3202        assert_eq!(plan("voro-core_v1.2").slug(), "plan-voro-core_v1.2");
3203
3204        // The other task-less launch reduces a name the same way, so the two
3205        // sessions a project can have without a task read alike.
3206        let propose = |name: &str| Launch::Propose {
3207            project: name.to_string(),
3208        };
3209        assert_eq!(propose("odm 2").session_name(), "voro-propose-odm-2");
3210        assert_eq!(propose("odm 2").slug(), "propose-odm-2");
3211        assert_eq!(propose("ODM").session_name(), "voro-propose-ODM");
3212        assert_eq!(
3213            propose("it's \"fine\"; rm -rf /").session_name(),
3214            "voro-propose-it-s--fine---rm--rf--"
3215        );
3216        assert_eq!(propose("a/b").slug(), "propose-a-b");
3217        assert_eq!(propose("voro-core_v1.2").slug(), "propose-voro-core_v1.2");
3218    }
3219
3220    #[test]
3221    fn builtin_claude_names_the_session_from_the_launch() {
3222        let config = AgentsConfig::builtin_only(Path::new("/tmp/voro.toml"));
3223        let claude = config.resolve(Some("claude")).unwrap();
3224        let dispatch = claude.launch_command(&spec(false));
3225        assert!(
3226            dispatch.contains("--name \"voro-7-widen-the-strip\""),
3227            "{dispatch}"
3228        );
3229
3230        let refined = claude.launch_command(&LaunchSpec {
3231            launch: Launch::Refine { task_id: 7 },
3232            ..spec(false)
3233        });
3234        assert!(refined.contains("--name \"voro-7-refine\""), "{refined}");
3235        assert_ne!(dispatch, refined);
3236
3237        // A planning session is named too, and `--name` is not a --bg-only
3238        // flag, so the foreground plan verb carries it.
3239        let planned = claude
3240            .plan_launch_command(&LaunchSpec {
3241                launch: Launch::Plan {
3242                    project: "mote".into(),
3243                },
3244                ..spec(false)
3245            })
3246            .unwrap();
3247        assert!(planned.contains("--name \"voro-plan-mote\""), "{planned}");
3248        assert!(!planned.contains("--bg"), "{planned}");
3249
3250        // Nothing reaches the shell as literal braces on any of them.
3251        for rendered in [dispatch, refined, planned] {
3252            assert!(!rendered.contains('{'), "unsubstituted: {rendered}");
3253        }
3254    }
3255
3256    #[test]
3257    fn the_prompt_file_is_shell_quoted_into_the_command() {
3258        let config = AgentsConfig::builtin_only(Path::new("/tmp/voro.toml"));
3259        let claude = config.resolve(Some("claude")).unwrap();
3260        let rendered = claude.launch_command(&LaunchSpec {
3261            prompt_file: Path::new("/tmp/a dir/p.md"),
3262            ..spec(false)
3263        });
3264        assert!(rendered.contains("cat '/tmp/a dir/p.md'"), "{rendered}");
3265    }
3266
3267    #[test]
3268    fn builtin_claude_renders_a_model_per_purpose_and_depth() {
3269        let config = AgentsConfig::builtin_only(Path::new("/tmp/voro.toml"));
3270        let claude = config.resolve(Some("claude")).unwrap();
3271        // A workhorse for ordinary implementation, the stronger model for a
3272        // deep task and for interactive planning; all `claude` model aliases,
3273        // so none churns with a release.
3274        assert!(
3275            claude.launch_command(&spec(false)).contains("--model opus"),
3276            "{}",
3277            claude.launch_command(&spec(false))
3278        );
3279        assert!(
3280            claude.launch_command(&spec(true)).contains("--model fable"),
3281            "{}",
3282            claude.launch_command(&spec(true))
3283        );
3284        let planned = claude.plan_launch_command(&spec(false)).unwrap();
3285        assert!(planned.contains("--model fable"), "{planned}");
3286        for rendered in [
3287            claude.launch_command(&spec(false)),
3288            claude.launch_command(&spec(true)),
3289            planned,
3290        ] {
3291            assert!(
3292                !rendered.contains(MODEL_PLACEHOLDER),
3293                "placeholder left unresolved: {rendered}"
3294            );
3295        }
3296    }
3297
3298    #[test]
3299    fn an_agent_without_the_placeholder_ignores_depth_entirely() {
3300        let config = AgentsConfig::builtin_only(Path::new("/tmp/voro.toml"));
3301        let codex = config.resolve(Some("codex")).unwrap();
3302        assert_eq!(
3303            codex.launch_command(&spec(true)),
3304            codex.launch_command(&spec(false))
3305        );
3306        assert_eq!(
3307            codex.launch_command(&spec(true)),
3308            "codex exec \"$(cat '/tmp/p.md')\""
3309        );
3310        assert_eq!(codex.plan_launch_command(&spec(false)), None);
3311    }
3312
3313    #[test]
3314    fn model_deep_and_model_plan_fall_back_to_model() {
3315        let text = r#"
3316            [agents.a]
3317            dispatch = "run --model {model} {prompt_file}"
3318            plan     = "run -i --model {model} {prompt_file}"
3319            model    = "workhorse"
3320        "#;
3321        let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
3322        let a = config.resolve(Some("a")).unwrap();
3323        assert_eq!(
3324            a.launch_command(&spec(false)),
3325            "run --model workhorse '/tmp/p.md'"
3326        );
3327        assert_eq!(
3328            a.launch_command(&spec(true)),
3329            "run --model workhorse '/tmp/p.md'"
3330        );
3331        assert_eq!(
3332            a.plan_launch_command(&spec(false)).unwrap(),
3333            "run -i --model workhorse '/tmp/p.md'"
3334        );
3335    }
3336
3337    #[test]
3338    fn the_placeholder_without_a_model_key_is_a_config_error() {
3339        let text = r#"
3340            [agents.a]
3341            dispatch = "run --model {model} {prompt_file}"
3342        "#;
3343        let message = AgentsConfig::parse(text, Path::new("/tmp/voro.toml"))
3344            .unwrap_err()
3345            .to_string();
3346        assert!(message.contains("{model}"), "{message}");
3347        assert!(message.contains("model = "), "{message}");
3348        assert!(message.contains("'a'"), "{message}");
3349
3350        // ...and the same when only `plan` carries it.
3351        let text = r#"
3352            [agents.a]
3353            dispatch = "run {prompt_file}"
3354            plan     = "run -i --model {model} {prompt_file}"
3355        "#;
3356        assert!(AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).is_err());
3357    }
3358
3359    /// Wholesale overrides written before the model map existed carry no
3360    /// `{model}`; the keys are inert there rather than newly required.
3361    #[test]
3362    fn model_keys_without_the_placeholder_are_inert_not_an_error() {
3363        let text = r#"
3364            [agents.claude]
3365            dispatch   = "claude -p {prompt_file}"
3366            model      = "opus"
3367            model_deep = "fable"
3368        "#;
3369        let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
3370        let claude = config.resolve(Some("claude")).unwrap();
3371        assert_eq!(claude.launch_command(&spec(true)), "claude -p '/tmp/p.md'");
3372        assert_eq!(claude.launch_command(&spec(false)), "claude -p '/tmp/p.md'");
3373    }
3374
3375    #[test]
3376    fn a_session_verb_carrying_the_placeholder_is_rejected() {
3377        let text = r#"
3378            [agents.a]
3379            dispatch = "run {prompt_file}"
3380            attach   = "reopen --model {model} {session}"
3381            model    = "workhorse"
3382        "#;
3383        let message = AgentsConfig::parse(text, Path::new("/tmp/voro.toml"))
3384            .unwrap_err()
3385            .to_string();
3386        assert!(message.contains("attach"), "{message}");
3387        assert!(message.contains("{model}"), "{message}");
3388    }
3389
3390    /// No launch placeholder may survive to a command line: one a renderer does
3391    /// not bind on that verb is refused at load rather than reaching the shell
3392    /// as literal braces (DESIGN.md §8).
3393    #[test]
3394    fn launch_placeholders_are_refused_on_the_verbs_that_cannot_bind_them() {
3395        for (verb, template) in [
3396            ("sessions", "list --name {session_name}"),
3397            ("attach", "reopen --name {session_name} {session}"),
3398            ("resume", "reopen {session} --for {task_id}"),
3399        ] {
3400            let text =
3401                format!("[agents.a]\ndispatch = \"run {{prompt_file}}\"\n{verb} = '{template}'\n");
3402            let message = AgentsConfig::parse(&text, Path::new("/tmp/voro.toml"))
3403                .unwrap_err()
3404                .to_string();
3405            assert!(message.contains(verb), "{verb}: {message}");
3406            assert!(message.contains("dispatch and plan"), "{verb}: {message}");
3407        }
3408
3409        // `plan` serves a target that has no task, so `{task_id}` is refused
3410        // there even though `dispatch` still honours it.
3411        let text = r#"
3412            [agents.a]
3413            dispatch = "run {prompt_file}"
3414            plan     = "run -i --name \"voro-{task_id}\" {prompt_file}"
3415        "#;
3416        let message = AgentsConfig::parse(text, Path::new("/tmp/voro.toml"))
3417            .unwrap_err()
3418            .to_string();
3419        assert!(message.contains("plan"), "{message}");
3420        assert!(message.contains(TASK_ID_PLACEHOLDER), "{message}");
3421        assert!(message.contains(SESSION_NAME_PLACEHOLDER), "{message}");
3422    }
3423
3424    #[test]
3425    fn the_session_name_placeholder_is_accepted_on_dispatch_and_plan() {
3426        let text = r#"
3427            [agents.a]
3428            dispatch = "run --name {session_name} --for {task_id} {prompt_file}"
3429            plan     = "run -i --name {session_name} {prompt_file}"
3430        "#;
3431        let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
3432        let a = config.resolve(Some("a")).unwrap();
3433        assert_eq!(
3434            a.launch_command(&spec(false)),
3435            "run --name voro-7-widen-the-strip --for 7 '/tmp/p.md'"
3436        );
3437        assert_eq!(
3438            a.plan_launch_command(&LaunchSpec {
3439                launch: Launch::Plan {
3440                    project: "mote".into(),
3441                },
3442                ..spec(false)
3443            })
3444            .unwrap(),
3445            "run -i --name voro-plan-mote '/tmp/p.md'"
3446        );
3447    }
3448
3449    /// What a headless launch records on its session row (DESIGN.md §8): an
3450    /// agent with a `sessions` verb may hand the work to a supervisor,
3451    /// so its listing is the authority; one without has only the pid Voro
3452    /// spawned.
3453    #[test]
3454    fn a_sessions_verb_makes_a_launch_listing_authoritative() {
3455        let text = r#"
3456            [agents.supervised]
3457            dispatch = "run --bg {prompt_file}"
3458            sessions = "run sessions --json"
3459
3460            [agents.plain]
3461            dispatch = "run {prompt_file}"
3462        "#;
3463        let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
3464        assert_eq!(
3465            config
3466                .resolve(Some("supervised"))
3467                .unwrap()
3468                .dispatch_liveness_source(),
3469            LivenessSource::Listing
3470        );
3471        assert_eq!(
3472            config
3473                .resolve(Some("plain"))
3474                .unwrap()
3475                .dispatch_liveness_source(),
3476            LivenessSource::Pid
3477        );
3478    }
3479
3480    #[test]
3481    fn default_agent_key_sets_the_default() {
3482        let text = r#"
3483            default_agent = "codex"
3484        "#;
3485        let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
3486        let both = |_: &str| true;
3487        assert_eq!(config.resolve_with(None, &both).unwrap().name, "codex");
3488    }
3489}