diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 01246a1..e3f5c7b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -23,7 +23,7 @@ Exactly one workspace is *open* at a time — daemon-global state, switched with Worktrees are real git worktrees, created under `/../-worktrees/`. The daemon also polls git metadata so worktrees created outside Nebula still show up. -An agent is a PTY running `claude`, `codex`, or `cursor-agent` in that worktree. Restart uses `--resume ` when one is stored. +An agent is a PTY running `claude`, `codex`, `cursor-agent`, `pi`, or `muse` in that worktree. Restart uses `--resume ` when one is stored (muse always boots fresh: no resume flag mapped yet). Persistence is SQLite at `~/.local/share/nebula/nebula.db`: workspaces (one flagged open), projects, worktrees, agents (kind + CLI session id, plus a PR URL when the AGENT was created from one), links, last UI selection. @@ -65,7 +65,7 @@ The ISSUES MODAL (`i`) is the same idea for GitHub issues: the TUI lists the sel **Settings path:** both halves read settings through `nebula-core/src/settings.rs`: `config.json` (or `NEBULA_CONFIG_FILE`) with `/config.local.json` merged over it key by key. When the merged object won't deserialize whole, it is read one top-level key at a time, so a value one release can't read costs only that key; the TUI keeps those keys in `Config::skipped`, and a save leaves their stored JSON alone and writes a key the local layer holds back into that layer. Writes go tmp-then-rename through `settings::write_atomic`, which follows a symlink to the file it names, and the presets and hosts lists are read entry by entry, with a save keeping the entries this build can't parse. `nebula-tui/src/bundle.rs` moves settings between machines as raw JSON sections under a `nebula_bundle` marker — never through the typed structs, so keys and fields a newer release wrote survive the trip. `nebula config export` / `import` write and merge one; `nebula ssh` and `nebula tunnel` base64 one (config and presets, not hosts) into one more positional parameter of their remote script, which exports it as `NEBULA_IMPORT_BUNDLE` before exec'ing nebula, and the binary's `main` merges it and removes the variable before any thread or child exists. A remote nebula too old to know the variable ignores it. -**Presets path:** `e` in the Sessions panel lists the agent presets in `/agent_presets.json` (`nebula-tui/src/agent_presets.rs`, a sibling of `ssh_hosts.json`, written tmp-then-rename): each is a name, an agent kind, a model / effort choice (or "follow Settings → Agents") and optional prefix / postfix text. `a` / `e` open the preset editor form, `d` deletes behind a confirm, and `Enter` opens the same multi-line task editor the cloud launch uses. Submitting composes `prefix + task + postfix` (blank-line separated, empty parts skipped, capped at the cloud task's 16 KiB) and sends `CreateAgent { starting_prompt }`; the daemon validates it, skips warm-spare adoption (a CLI already booted bare cannot be handed an argument) and passes it to the fresh spawn as the CLI's trailing positional prompt (`claude … ""`, `codex … ""`, `cursor-agent … ""`). Like the cloud task it is request-only — nothing is persisted, so a restart or resume rebuilds the ordinary argv — and the row it creates is an ordinary agent from then on (auto-title, hooks, status, resume). +**Presets path:** `e` in the Sessions panel lists the agent presets in `/agent_presets.json` (`nebula-tui/src/agent_presets.rs`, a sibling of `ssh_hosts.json`, written tmp-then-rename): each is a name, an agent kind, a model / effort choice (or "follow Settings → Agents") and optional prefix / postfix text. `a` / `e` open the preset editor form, `d` deletes behind a confirm, and `Enter` opens the same multi-line task editor the cloud launch uses. Submitting composes `prefix + task + postfix` (blank-line separated, empty parts skipped, capped at the cloud task's 16 KiB) and sends `CreateAgent { starting_prompt }`; the daemon validates it, skips warm-spare adoption (a CLI already booted bare cannot be handed an argument) and passes it to the fresh spawn as the CLI's trailing positional prompt (`claude … ""`, `codex … ""`, `cursor-agent … ""`, `pi … ""`, `muse … ""`). Like the cloud task it is request-only — nothing is persisted, so a restart or resume rebuilds the ordinary argv — and the row it creates is an ordinary agent from then on (auto-title, hooks, status, resume). **Cursor catalogue path:** `cursor-agent --model` takes one flat id per (family, effort, fast) triple — `claude-opus-5-thinking-high-fast` — and refuses the bracket form its `--help` shows, so the TUI keeps the family as the model and the suffix (`high`, `high-fast`, `fast`) as the effort and the daemon joins them with a `-` at spawn (`registry.rs::agent_spawn_command_with`). `nebula-tui/src/cursor_catalogue.rs` builds the lists from one parser over two sources: a built-in seed of the ids observed on 2026-08-28 and `cursor-agent --list-models`, which `bootstrap` (TUI startup, skipped when Cursor is switched off or under `NEBULA_AGENT_CMD`) reads from `/cursor_models.json` and refreshes on a background thread once the cache is a day old; the two are unioned, seed order first, because `--list-models` prints only the featured ids. Most families have no bare id, so "default" effort for them launches the family's fallback (`high`, else `medium`, else its first variant) — `config::fit_effort` is the one place that decides, and every surface that changes the family (Agents tab, preset editor, picker) runs it. diff --git a/README.md b/README.md index 1443626..42a7dbb 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ **Mission control for your coding agents.** -Run **Claude Code**, **Codex**, **Cursor** and **Pi** across every project and git WORKTREE you own — from one +Run **Claude Code**, **Codex**, **Cursor**, **Pi** and **Muse** across every project and git WORKTREE you own — from one terminal, one keyboard, one tree. They keep working when you close it. [![Release](https://img.shields.io/github/v/release/AgentSystemLabs/nebula?style=flat-square&color=e8c547&label=release)](https://github.com/AgentSystemLabs/nebula/releases) @@ -73,7 +73,7 @@ build speaks a different protocol, in which case it can't attach until that rest says so and offers to do it for you. `nebula --version` (`-V`) says which binary you are on. -> **Prerequisite:** at least one agent CLI on your `PATH` — `claude`, `codex`, `cursor-agent`, or `pi`. +> **Prerequisite:** at least one agent CLI on your `PATH` — `claude`, `codex`, `cursor-agent`, `pi`, or `muse`. > nebula spawns them; it doesn't ship them. > > Three commands each want one more binary, and only those commands: `nebula ssh` and `nebula tunnel` @@ -102,7 +102,7 @@ in the WORKTREES PANEL to branch off into a real `git worktree`. That's the whol two agents in two WORKTREES edit two directories and never collide. **4. Start the agent.** `n` in the SESSIONS PANEL opens the NEW SESSION PICKER — **Claude**, **Codex**, -**Cursor** or **Pi**, `→` for MODEL and EFFORT, `Enter` for your defaults — then type the agent's first prompt +**Cursor**, **Pi** or **Muse**, `→` for MODEL and EFFORT, `Enter` for your defaults — then type the agent's first prompt in the box that follows (or `Enter` on it empty to start in the CLI). Or skip the picker entirely: `p` from any PANEL opens the QUICK PROMPT, you type the task, and an agent starts working on it in the selected WORKTREE — or, from the WORKTREES PANEL or with `Ctrl+N` inside the box, in a fresh worktree cut for the @@ -132,7 +132,8 @@ Claude's prompt box and `/resume` picker on your next prompt. | ○ | disconnected — the DAEMON restarted while the agent was live | A Cursor SESSION never goes red: nebula runs `cursor-agent --force` and Cursor reports no permission -event, so waiting-on-you is not detectable there. +event, so waiting-on-you is not detectable there. A Muse SESSION never goes red either yet: `muse` +has no managed hooks, so its status is process-based until a hook dialect is mapped. WORKTREE and PROJECT rows ROLL UP their children: red beats yellow beats done, and a parent's dot is violet whenever anything UNSEEN finished under it — so the violet walks up the tree and turns green as diff --git a/crates/nebula-core/src/entities.rs b/crates/nebula-core/src/entities.rs index 4e3da21..64e3595 100644 --- a/crates/nebula-core/src/entities.rs +++ b/crates/nebula-core/src/entities.rs @@ -56,16 +56,29 @@ pub enum AgentKind { /// `@earendil-works/pi-coding-agent`). Status comes from a managed /// TypeScript extension rather than shell hooks. Pi, + /// Meta's Muse Spark coding agent: the `muse` CLI. No managed + /// hooks yet, so status is process-based (running while the PTY + /// is live) until a hook dialect is mapped. + Muse, + /// A user-defined harness from the `custom_harnesses` registry: the + /// entry id travels beside the session (see `Agent::custom_harness`), + /// never in this variant. Launches with the entry's program and model + /// flag, with process-based status and no resume — like [`AgentKind::Muse`]. + Custom, } impl AgentKind { /// Every kind, for callers that must cover all of them (menus, the /// boot-time CLI probe warm) and should fail to compile if one is added. - pub const ALL: [AgentKind; 4] = [ + /// `Custom` rides along: it never launches without its registry entry, + /// so loops over ALL skip it explicitly where a bare kind is meaningless. + pub const ALL: [AgentKind; 6] = [ AgentKind::Claude, AgentKind::Codex, AgentKind::Cursor, AgentKind::Pi, + AgentKind::Muse, + AgentKind::Custom, ]; pub fn as_str(&self) -> &'static str { @@ -74,27 +87,39 @@ impl AgentKind { AgentKind::Codex => "codex", AgentKind::Cursor => "cursor", AgentKind::Pi => "pi", + AgentKind::Muse => "muse", + AgentKind::Custom => "custom", } } + /// Parse a harness name from settings or the CLI. Bare `"custom"` + /// never parses: a custom harness is meaningless without its registry + /// id, which travels in its own field. pub fn parse(s: &str) -> Option { Some(match s { "claude" => AgentKind::Claude, "codex" => AgentKind::Codex, "cursor" => AgentKind::Cursor, "pi" => AgentKind::Pi, + "muse" => AgentKind::Muse, _ => return None, }) } /// Binary the kind launches. Differs from `as_str` only for Cursor, /// whose agent CLI ships as `cursor-agent` (`cursor` opens the editor). + /// `Custom` has no static program — its entry names it — so every + /// launch path resolves through the harness registry first; the + /// placeholder below only surfaces as a "not found on PATH" error if + /// one ever launches it bare. pub fn cli_program(&self) -> &'static str { match self { AgentKind::Claude => "claude", AgentKind::Codex => "codex", AgentKind::Cursor => "cursor-agent", AgentKind::Pi => "pi", + AgentKind::Muse => "muse", + AgentKind::Custom => "custom", } } } @@ -174,6 +199,11 @@ pub struct Agent { pub status_changed_at: i64, #[serde(default)] pub kind: AgentKind, + /// Registry id of the custom harness, when `kind` is + /// [`AgentKind::Custom`]. Persisted beside the row so respawns find + /// the same entry; None for every built-in harness. + #[serde(default)] + pub custom_harness: Option, /// Model the CLI is launched with (claude `--model` / codex `-m`); /// None = the CLI's own default. Persisted so respawns keep it. #[serde(default)] diff --git a/crates/nebula-core/src/harness.rs b/crates/nebula-core/src/harness.rs new file mode 100644 index 0000000..42b7319 --- /dev/null +++ b/crates/nebula-core/src/harness.rs @@ -0,0 +1,1120 @@ +//! The harness registry: one behavior descriptor per agent CLI. +//! +//! The five known harnesses ship as a compiled-in table ([`builtin`]); +//! the user's config adds a `harnesses` map of [`HarnessOverride`]s over +//! it — disable one, repoint a program, rename a flag, or define a whole +//! new CLI — and [`registry`] merges the two (plus the legacy +//! [`CustomHarness`] list) into the effective [`HarnessDescriptor`]s every +//! surface reads: the `n` picker, the `e` presets, spawn and resume, hooks, +//! and the Agents tab. Adding a CLI is a config edit; the verification is +//! that every behavior below is data, with the three genuinely bespoke arg +//! shapings (Cursor's composed model id, Codex's `-c` config pair and its +//! positional resume, Cloud's `--cloud=`) carried as descriptor fields. +//! +//! [`AgentKind`](crate::AgentKind) stays the identity — built-ins by +//! variant, customs by id beside `AgentKind::Custom` — so exhaustive +//! matches still fail to compile when a built-in is added. Behavior never +//! branches on it directly anymore; it resolves to a descriptor first. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use crate::AgentKind; + +/// A model id the pickers offer, verbatim, before the `default` sentinel +/// the UI heads every list with. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ModelEntry { + /// The id passed to the CLI. + pub id: String, + /// One-line note the pickers show beside it (provider, generation). + /// None renders no note. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hint: Option, +} + +/// Which TUI-side catalogue supplies a harness's model (and, for Cursor, +/// effort) rows at runtime, in place of the descriptor's static lists: +/// Claude's `claude_models` / `availableModels` allowlist, or Cursor's +/// `--list-models` union over its seed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HarnessCatalog { + Claude, + Cursor, +} + +impl HarnessCatalog { + pub fn as_str(self) -> &'static str { + match self { + HarnessCatalog::Claude => "claude", + HarnessCatalog::Cursor => "cursor", + } + } + + pub fn parse(s: &str) -> Option { + Some(match s.trim() { + "claude" => HarnessCatalog::Claude, + "cursor" => HarnessCatalog::Cursor, + _ => return None, + }) + } +} + +/// How a harness takes its model: the flag carrying it, the default the +/// Agents tab edits, and the static list the pickers offer (before the +/// `default` sentinel, which always means "don't pass the flag"). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ModelSpec { + /// Flag carrying the model id (`--model`). None = the CLI takes no + /// model flag; the Model row still edits the stored default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub flag: Option, + /// Default model id. `"default"` means don't pass the flag. + #[serde(default = "default_model_choice")] + pub default: String, + /// Static model list; a [`HarnessCatalog`] replaces it at runtime. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub models: Vec, + /// Runtime catalogue replacing [`ModelSpec::models`], if any. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub catalog: Option, +} + +/// How a harness takes its reasoning effort: a plain flag (`--effort`, +/// `--thinking`), Codex's `-c model_reasoning_effort=` config pair, or +/// nothing (composed into the model id, or unsupported). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EffortSpec { + /// Flag carrying the effort (`--effort`). Mutually exclusive with + /// `config_key`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub flag: Option, + /// The `-c` style flag introducing `config_key=value` pairs. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config_flag: Option, + /// The config key, carried by `config_flag` as `key=value`. Requires + /// `config_flag`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config_key: Option, + /// Default effort. `"default"` means don't pass anything. + #[serde(default = "default_model_choice")] + pub default: String, + /// Static effort list, in the CLI's own order. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub efforts: Vec, + /// Whether effort is a concept for this harness at all: the Agents + /// tab's Effort row and the effort submenus show only while set. Muse + /// keeps a reserved row (stored, never sent); legacy customs have none. + #[serde(default)] + pub offered: bool, +} + +/// How a harness resumes a stored CLI session id: a flag (`--resume`, +/// `--session-id`), Codex's positional subcommand (`resume `, with +/// `--cd`), or nothing (the CLI boots fresh and the id is ignored). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResumeSpec { + /// Resume flag. Mutually exclusive with `subcommand`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub flag: Option, + /// Positional subcommand naming the resume (`resume`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subcommand: Option, + /// Pass the worktree as `--cd ` after the id. Only meaningful + /// with `subcommand`. + #[serde(default, skip_serializing_if = "is_false")] + pub cd: bool, +} + +/// How nebula's guidance (worktree rules, PR scope) reaches the CLI: +/// appended to the system prompt through a flag, prepended to the first +/// prompt, or dropped. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SystemSpec { + /// System-prompt flag (`--append-system-prompt`). Mutually exclusive + /// with `prepend_to_first_prompt`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub append_flag: Option, + /// Fold the guidance into the first prompt instead. + #[serde(default, skip_serializing_if = "is_false")] + pub prepend_to_first_prompt: bool, +} + +/// The effective behavior of one harness: what the picker offers, what +/// spawn builds, which hooks install, and what the Agents tab edits. +/// Built-ins come from [`builtin`]; user entries merge over them (or over +/// the legacy-custom shape for new ids) through [`HarnessOverride`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HarnessDescriptor { + /// Stable id: the builtin name or the custom entry id. + pub id: String, + /// Display label for the picker, badges and tab sections. + pub label: String, + /// The CLI nebula launches, resolved on PATH through the login shell. + pub program: String, + /// Whether the picker and presets offer this harness. + #[serde(default = "default_enabled")] + pub enabled: bool, + /// Model flag, default and list. + #[serde(default)] + pub model: ModelSpec, + /// Effort passing, default and list. + #[serde(default)] + pub effort: EffortSpec, + /// Skip-permissions flag (`--yolo`, `--force`). None = the CLI has + /// none and launches as-is. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permissions_flag: Option, + /// How a stored session id resumes. + #[serde(default)] + pub resume: ResumeSpec, + /// How guidance text reaches the CLI. + #[serde(default)] + pub system: SystemSpec, + /// Hook dialect to install, naming the built-in CLI whose hooks this + /// harness speaks (`claude`, `codex`, `cursor`, `pi`). Sessions report + /// status, prompts and permission waits like that harness; without one + /// they stay process-based. A Claude-compatible CLI gets title sync + /// with `claude`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hooks: Option, + /// Whether a relocated session resumes with the trailing "continue in + /// this checkout" prompt (only for CLIs verified to open a resumed + /// session on one). + #[serde(default, skip_serializing_if = "is_false")] + pub relocation_prompt: bool, + /// Whether model and effort compose into one `--model {m}-{e}` id + /// (Cursor's family-suffix shape). + #[serde(default, skip_serializing_if = "is_false")] + pub compose_model_effort: bool, +} + +fn is_false(value: &bool) -> bool { + !value +} + +fn default_enabled() -> bool { + true +} + +fn default_model_choice() -> String { + "default".into() +} + +impl Default for ModelSpec { + fn default() -> Self { + Self { + flag: Some("--model".into()), + default: default_model_choice(), + models: Vec::new(), + catalog: None, + } + } +} + +impl Default for EffortSpec { + fn default() -> Self { + Self { + flag: None, + config_flag: None, + config_key: None, + default: default_model_choice(), + efforts: Vec::new(), + offered: false, + } + } +} + +impl Default for ResumeSpec { + fn default() -> Self { + Self { + flag: None, + subcommand: None, + cd: false, + } + } +} + +impl Default for SystemSpec { + fn default() -> Self { + Self { + append_flag: None, + prepend_to_first_prompt: false, + } + } +} + +impl HarnessDescriptor { + /// The label the picker and session rows show. + pub fn display_label(&self) -> &str { + if self.label.trim().is_empty() { + &self.id + } else { + &self.label + } + } + + /// The configured default model, or None for `"default"` (don't pass + /// the flag) — the same sentinel the model settings use. + pub fn default_model(&self) -> Option<&str> { + let value = self.model.default.trim(); + (!value.is_empty() && !value.eq_ignore_ascii_case("default")).then_some(value) + } + + /// The configured default effort, or None for `"default"`. Callers + /// composing Cursor ids fit it against the family first. + pub fn default_effort(&self) -> Option<&str> { + let value = self.effort.default.trim(); + (!value.is_empty() && !value.eq_ignore_ascii_case("default")).then_some(value) + } + + /// Whether this harness resumes `sid` in place (as opposed to booting + /// fresh and ignoring it). + pub fn resumes(&self) -> bool { + self.resume.flag.is_some() || self.resume.subcommand.is_some() + } + + /// Whether the harness takes a system prompt through a flag. + pub fn system_append_flag(&self) -> Option<&str> { + self.system.append_flag.as_deref() + } + + /// The hook dialect this harness's sessions install, if any. + pub fn hook_dialect(&self) -> Option { + match self.hooks.as_deref().map(str::trim) { + Some("claude") => Some(crate::AgentKind::Claude), + Some("codex") => Some(crate::AgentKind::Codex), + Some("cursor") => Some(crate::AgentKind::Cursor), + Some("pi") => Some(crate::AgentKind::Pi), + _ => None, + } + } + + /// Whether rows on this harness title-push and auto-title like Claude: + /// any harness speaking its hook dialect, builtin Claude included. The + /// title travels in hook replies, so a cleared `hooks` row is not + /// claude-like even when its id is `claude`. + pub fn claude_like(&self) -> bool { + self.hook_dialect() == Some(AgentKind::Claude) + } + + /// Why this descriptor is unusable, or None when it launches. The + /// picker hides broken entries; create paths refuse them with this. + pub fn problem(&self) -> Option { + if self.id.trim().is_empty() { + return Some("harness with an empty id".into()); + } + if self.program.trim().is_empty() { + return Some(format!("harness `{}` has no program", self.id.trim())); + } + if self.resume.flag.is_some() && self.resume.subcommand.is_some() { + return Some(format!( + "harness `{}` sets both a resume flag and a resume subcommand", + self.id.trim() + )); + } + if self.resume.cd && self.resume.subcommand.is_none() { + return Some(format!( + "harness `{}` passes --cd without a resume subcommand", + self.id.trim() + )); + } + if self.effort.flag.is_some() && self.effort.config_key.is_some() { + return Some(format!( + "harness `{}` sets both an effort flag and an effort config key", + self.id.trim() + )); + } + if self.effort.config_key.is_some() && self.effort.config_flag.is_none() { + return Some(format!( + "harness `{}` sets an effort config key without its flag", + self.id.trim() + )); + } + if self.system.append_flag.is_some() && self.system.prepend_to_first_prompt { + return Some(format!( + "harness `{}` sets both a system-prompt flag and first-prompt prepend", + self.id.trim() + )); + } + if self.compose_model_effort && self.model.flag.is_none() { + return Some(format!( + "harness `{}` composes model-effort ids without a model flag", + self.id.trim() + )); + } + if let Some(dialect) = self.hooks.as_deref().map(str::trim) { + match dialect { + "claude" | "codex" | "cursor" | "pi" => {} + _ => { + return Some(format!( + "harness `{}` hooks `{dialect}`: name a built-in dialect (claude, codex, cursor, pi)", + self.id.trim() + )); + } + } + } + None + } +} + +fn models(ids: &[&str]) -> Vec { + ids.iter() + .map(|id| ModelEntry { + id: id.to_string(), + hint: None, + }) + .collect() +} + +/// The compiled-in behavior of a known harness: what a fresh install +/// runs before any config exists. The Agents tab, the picker, spawn and +/// hooks all read through here, so a new built-in is a new row plus its +/// exhaustive-match arms — and a new third-party CLI needs no row at all. +pub fn builtin(id: &str) -> Option { + let base = HarnessDescriptor { + id: id.trim().to_string(), + label: String::new(), + program: String::new(), + enabled: true, + model: ModelSpec::default(), + effort: EffortSpec::default(), + permissions_flag: None, + resume: ResumeSpec::default(), + system: SystemSpec::default(), + hooks: None, + relocation_prompt: false, + compose_model_effort: false, + }; + Some(match id.trim() { + "claude" => HarnessDescriptor { + label: "Claude".into(), + program: "claude".into(), + model: ModelSpec { + flag: Some("--model".into()), + default: default_model_choice(), + models: models(&["fable", "opus", "sonnet", "haiku"]), + catalog: Some(HarnessCatalog::Claude), + }, + effort: EffortSpec { + flag: Some("--effort".into()), + default: default_model_choice(), + efforts: ["low", "medium", "high", "xhigh", "max"] + .iter() + .map(ToString::to_string) + .collect(), + offered: true, + ..EffortSpec::default() + }, + resume: ResumeSpec { + flag: Some("--resume".into()), + ..ResumeSpec::default() + }, + system: SystemSpec { + append_flag: Some("--append-system-prompt".into()), + ..SystemSpec::default() + }, + hooks: Some("claude".into()), + relocation_prompt: true, + ..base + }, + "codex" => HarnessDescriptor { + label: "Codex".into(), + program: "codex".into(), + model: ModelSpec { + flag: Some("--model".into()), + default: default_model_choice(), + models: models(&["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5"]), + catalog: None, + }, + effort: EffortSpec { + config_flag: Some("-c".into()), + config_key: Some("model_reasoning_effort".into()), + default: default_model_choice(), + efforts: ["minimal", "low", "medium", "high", "xhigh"] + .iter() + .map(ToString::to_string) + .collect(), + offered: true, + ..EffortSpec::default() + }, + permissions_flag: Some("--yolo".into()), + resume: ResumeSpec { + flag: None, + subcommand: Some("resume".into()), + cd: true, + }, + hooks: Some("codex".into()), + relocation_prompt: true, + ..base + }, + "cursor" => HarnessDescriptor { + label: "Cursor".into(), + program: "cursor-agent".into(), + model: ModelSpec { + flag: Some("--model".into()), + default: default_model_choice(), + models: Vec::new(), + catalog: Some(HarnessCatalog::Cursor), + }, + effort: EffortSpec { + default: default_model_choice(), + offered: true, + ..EffortSpec::default() + }, + permissions_flag: Some("--force".into()), + resume: ResumeSpec { + flag: Some("--resume".into()), + ..ResumeSpec::default() + }, + hooks: Some("cursor".into()), + compose_model_effort: true, + ..base + }, + "pi" => HarnessDescriptor { + label: "Pi".into(), + program: "pi".into(), + model: ModelSpec { + flag: Some("--model".into()), + default: default_model_choice(), + models: models(&["opus", "sonnet", "haiku", "gpt-5.5"]), + catalog: None, + }, + effort: EffortSpec { + flag: Some("--thinking".into()), + default: default_model_choice(), + efforts: ["off", "minimal", "low", "medium", "high", "xhigh", "max"] + .iter() + .map(ToString::to_string) + .collect(), + offered: true, + ..EffortSpec::default() + }, + resume: ResumeSpec { + flag: Some("--session-id".into()), + ..ResumeSpec::default() + }, + system: SystemSpec { + append_flag: Some("--append-system-prompt".into()), + ..SystemSpec::default() + }, + hooks: Some("pi".into()), + relocation_prompt: true, + ..base + }, + "muse" => HarnessDescriptor { + label: "Muse".into(), + program: "muse".into(), + model: ModelSpec { + flag: Some("--model".into()), + default: default_model_choice(), + models: Vec::new(), + catalog: None, + }, + effort: EffortSpec { + default: default_model_choice(), + offered: true, + ..EffortSpec::default() + }, + ..base + }, + _ => return None, + }) +} + +/// Every known harness, in [`AgentKind::ALL`] order. +pub fn builtins() -> Vec { + AgentKind::ALL + .into_iter() + .filter_map(|kind| { + (kind != AgentKind::Custom).then(|| { + builtin(kind.as_str()).expect("every built-in AgentKind has a descriptor row") + }) + }) + .collect() +} + +/// One delta to a harness row: absent leaves the row alone, `null` clears +/// a nullable row, a value replaces it. Plain `Option` cannot tell absent +/// from `null` (serde reads both as `None`), so nullable rows use this. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum Clearable { + /// The key is absent: keep the row's value. + #[default] + Keep, + /// The key is `null`: clear the row. + Clear, + /// The key names a value: use it. + Set(T), +} + +impl Clearable { + fn is_keep(value: &Clearable) -> bool { + matches!(value, Clearable::Keep) + } + + fn apply_to(&self, current: &mut Option) + where + T: Clone, + { + match self { + Clearable::Keep => {} + Clearable::Clear => *current = None, + Clearable::Set(value) => *current = Some(value.clone()), + } + } +} + +impl<'de, T: Deserialize<'de>> Deserialize<'de> for Clearable { + fn deserialize>(into: D) -> Result { + Option::::deserialize(into).map(|opt| match opt { + None => Clearable::Clear, + Some(value) => Clearable::Set(value), + }) + } +} + +impl Serialize for Clearable { + fn serialize(&self, out: S) -> Result { + match self { + Clearable::Keep => out.serialize_none(), + Clearable::Clear => out.serialize_none(), + Clearable::Set(value) => value.serialize(out), + } + } +} + +/// One entry of the config file's `harnesses` map: the user's deltas over +/// a built-in row, or a whole new third-party harness. Every field is +/// optional; the merge applies what is set. Nullable rows (`program`, +/// `model_flag`, `permissions_flag`, `hooks`, …) are [`Clearable`] — a +/// value replaces the row and `null` clears it — so a built-in behavior +/// can be switched off as well as renamed. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct HarnessOverride { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, + #[serde(default, skip_serializing_if = "Clearable::is_keep")] + pub program: Clearable, + #[serde(default, skip_serializing_if = "Clearable::is_keep")] + pub model_flag: Clearable, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_default: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub models: Option>, + #[serde(default, skip_serializing_if = "Clearable::is_keep")] + pub catalog: Clearable, + #[serde(default, skip_serializing_if = "Clearable::is_keep")] + pub effort_flag: Clearable, + #[serde(default, skip_serializing_if = "Clearable::is_keep")] + pub effort_config_flag: Clearable, + #[serde(default, skip_serializing_if = "Clearable::is_keep")] + pub effort_config_key: Clearable, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effort_default: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub efforts: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effort_offered: Option, + #[serde(default, skip_serializing_if = "Clearable::is_keep")] + pub permissions_flag: Clearable, + #[serde(default, skip_serializing_if = "Clearable::is_keep")] + pub resume_flag: Clearable, + #[serde(default, skip_serializing_if = "Clearable::is_keep")] + pub resume_subcommand: Clearable, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resume_cd: Option, + #[serde(default, skip_serializing_if = "Clearable::is_keep")] + pub system_append_flag: Clearable, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub system_prepend_to_first_prompt: Option, + #[serde(default, skip_serializing_if = "Clearable::is_keep")] + pub hooks: Clearable, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub relocation_prompt: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub compose_model_effort: Option, +} + +impl HarnessDescriptor { + /// Apply the user's deltas in place. Set fields replace the row's; + /// `null` clears a nullable one (a cleared program fails [`HarnessDescriptor::problem`] + /// with its reason rather than launching nothing). + pub fn apply(&mut self, over: &HarnessOverride) { + if let Some(enabled) = over.enabled { + self.enabled = enabled; + } + if let Some(label) = over.label.as_deref() { + self.label = label.to_string(); + } + match over.program.clone() { + Clearable::Keep => {} + Clearable::Clear => self.program.clear(), + Clearable::Set(program) => self.program = program, + } + over.model_flag.apply_to(&mut self.model.flag); + if let Some(default) = over.model_default.as_deref() { + self.model.default = default.to_string(); + } + if let Some(models) = over.models.clone() { + self.model.models = models; + } + over.catalog.apply_to(&mut self.model.catalog); + over.effort_flag.apply_to(&mut self.effort.flag); + over.effort_config_flag + .apply_to(&mut self.effort.config_flag); + over.effort_config_key.apply_to(&mut self.effort.config_key); + if let Some(default) = over.effort_default.as_deref() { + self.effort.default = default.to_string(); + } + if let Some(efforts) = over.efforts.clone() { + self.effort.efforts = efforts; + } + if let Some(offered) = over.effort_offered { + self.effort.offered = offered; + } + over.permissions_flag.apply_to(&mut self.permissions_flag); + over.resume_flag.apply_to(&mut self.resume.flag); + over.resume_subcommand.apply_to(&mut self.resume.subcommand); + if let Some(cd) = over.resume_cd { + self.resume.cd = cd; + } + over.system_append_flag.apply_to(&mut self.system.append_flag); + if let Some(prepend) = over.system_prepend_to_first_prompt { + self.system.prepend_to_first_prompt = prepend; + } + over.hooks.apply_to(&mut self.hooks); + if let Some(relocation) = over.relocation_prompt { + self.relocation_prompt = relocation; + } + if let Some(compose) = over.compose_model_effort { + self.compose_model_effort = compose; + } + } +} + +/// One user-defined harness, as the legacy `custom_harnesses` key holds +/// it. New harnesses belong in the `harnesses` map as full descriptors; +/// this list keeps reading, converting each entry to the descriptor shape +/// it always had (model flag, no effort row, no resume, optional hook +/// dialect). An id the map also names merges the map's deltas over the +/// entry. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomHarness { + /// Stable id: what the picker shows the session under when the label + /// is empty, and what persisted sessions point back at. Lowercase + /// letters, digits and hyphens; must be unique within the list and + /// must not collide with a built-in id (`claude`, `codex`, `cursor`, + /// `pi`, `muse`). + pub id: String, + /// Display label for the picker and session rows. Empty falls back + /// to the id. + #[serde(default)] + pub label: String, + /// The CLI nebula launches, resolved on PATH through the login shell + /// like every other harness. + pub program: String, + /// Whether the NEW SESSION PICKER offers this entry. + #[serde(default = "default_enabled")] + pub enabled: bool, + /// Default model id. `"default"` (the default) means don't pass the + /// flag — let the CLI pick; any other value is passed verbatim. + #[serde(default = "default_model_choice")] + pub model: String, + /// The flag that carries the model id. `"--model"` unless the CLI + /// spells it otherwise. + #[serde(default = "default_model_flag")] + pub model_flag: String, + /// Hook dialect to install for this entry's sessions, naming the + /// built-in CLI whose hooks the entry's program speaks: `"claude"`, + /// `"codex"`, `"cursor"` or `"pi"`. With one set the sessions report + /// status, prompts and permission waits exactly like that harness; + /// without one they stay process-based (running while the PTY is + /// live, never waiting-on-you). A Claude-compatible CLI gets title + /// sync and auto-title with `"claude"`. + #[serde(default)] + pub hooks: Option, +} + +fn default_model_flag() -> String { + "--model".into() +} + +impl CustomHarness { + /// The label the picker and session rows show. + pub fn display_label(&self) -> &str { + if self.label.trim().is_empty() { + &self.id + } else { + &self.label + } + } + + /// The descriptor shape this entry always had, before the map's + /// deltas (if any) apply over it. + pub fn as_descriptor(&self) -> HarnessDescriptor { + HarnessDescriptor { + id: self.id.clone(), + label: self.label.clone(), + program: self.program.clone(), + enabled: self.enabled, + model: ModelSpec { + flag: Some(self.model_flag.clone()), + default: self.model.clone(), + models: Vec::new(), + catalog: None, + }, + effort: EffortSpec::default(), + permissions_flag: None, + resume: ResumeSpec::default(), + system: SystemSpec::default(), + hooks: self.hooks.clone(), + relocation_prompt: false, + compose_model_effort: false, + } + } + + /// Why this entry is unusable, or None when it launches. The picker + /// hides invalid entries; create paths refuse them with this message. + pub fn problem(&self) -> Option { + if self.id.trim().is_empty() { + return Some("custom harness with an empty id".into()); + } + if !self + .id + .trim() + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + { + return Some(format!( + "custom harness id `{}`: use lowercase letters, digits and hyphens", + self.id.trim() + )); + } + if crate::AgentKind::parse(self.id.trim()).is_some() { + return Some(format!( + "custom harness id `{}` collides with a built-in harness", + self.id.trim() + )); + } + self.as_descriptor().problem() + } + + /// The hook dialect this entry's sessions install, if any. + pub fn hook_dialect(&self) -> Option { + self.as_descriptor().hook_dialect() + } +} + +/// The effective registry both halves of nebula read: the built-ins in +/// [`AgentKind::ALL`] order with the `harnesses` map applied, then the +/// legacy list in order (each with its map deltas), then map-only new ids +/// in key order. Callers hide entries that are disabled or [`problem`][HarnessDescriptor::problem]-broken; +/// see [`usable`]. +pub fn registry( + overrides: &BTreeMap, + customs: &[CustomHarness], +) -> Vec { + let mut out: Vec = builtins() + .into_iter() + .map(|mut descriptor| { + if let Some(over) = overrides.get(&descriptor.id) { + descriptor.apply(over); + } + descriptor + }) + .collect(); + for entry in customs { + let mut descriptor = entry.as_descriptor(); + if let Some(over) = overrides.get(&descriptor.id) { + descriptor.apply(over); + } + out.push(descriptor); + } + let mut extra: Vec<&String> = overrides + .keys() + .filter(|id| builtin(id).is_none() && !customs.iter().any(|entry| &entry.id == *id)) + .collect(); + extra.sort(); + for id in extra { + let mut descriptor = HarnessDescriptor { + id: id.clone(), + label: String::new(), + program: String::new(), + enabled: true, + model: ModelSpec::default(), + effort: EffortSpec::default(), + permissions_flag: None, + resume: ResumeSpec::default(), + system: SystemSpec::default(), + hooks: None, + relocation_prompt: false, + compose_model_effort: false, + }; + // A map-only entry that maps effort shows its Effort row like a + // built-in; a bare program stays Model-only like a legacy custom. + if let Some(over) = overrides.get(id) { + descriptor.apply(over); + if over.effort_offered.is_none() + && (descriptor.effort.flag.is_some() + || descriptor.effort.config_key.is_some() + || !descriptor.effort.efforts.is_empty()) + { + descriptor.effort.offered = true; + } + } + out.push(descriptor); + } + out +} + +/// Every entry the picker and presets offer: enabled and valid, in +/// registry order. Invalid entries are left out (create paths refuse them +/// with their reason); the Agents tab still lists them so they can be +/// fixed. +pub fn usable(all: &[HarnessDescriptor]) -> Vec<&HarnessDescriptor> { + all.iter() + .filter(|entry| entry.enabled && entry.problem().is_none()) + .collect() +} + +/// Resolve a session's harness to its effective descriptor: built-ins by +/// kind, customs by registry id. `Err` names why the launch is refused — +/// an unknown id, or the entry's [`problem`][HarnessDescriptor::problem]. +pub fn resolve<'a>( + all: &'a [HarnessDescriptor], + kind: AgentKind, + custom: Option<&str>, +) -> Result<&'a HarnessDescriptor, String> { + let id = match kind { + AgentKind::Custom => custom.unwrap_or_default().trim(), + _ => kind.as_str(), + }; + let Some(descriptor) = all.iter().find(|entry| entry.id == id) else { + if kind == AgentKind::Custom { + return Err(format!("custom harness `{id}` is no longer defined")); + } + return Err(format!("harness `{id}` is not in the registry")); + }; + if let Some(problem) = descriptor.problem() { + return Err(problem); + } + Ok(descriptor) +} + +/// Find a usable legacy entry by id: present, valid, and enabled. +pub fn find_custom<'a>(list: &'a [CustomHarness], id: &str) -> Option<&'a CustomHarness> { + list.iter().find(|entry| { + entry.id.trim() == id.trim() && entry.enabled && entry.problem().is_none() + }) +} + +/// Every usable legacy entry, in list order. +pub fn usable_custom<'a>(list: &'a [CustomHarness]) -> Vec<&'a CustomHarness> { + list.iter() + .filter(|entry| entry.enabled && entry.problem().is_none()) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn custom(id: &str) -> CustomHarness { + CustomHarness { + id: id.into(), + label: String::new(), + program: "agy".into(), + enabled: true, + model: default_model_choice(), + model_flag: default_model_flag(), + hooks: None, + } + } + + #[test] + fn builtins_cover_every_builtin_kind() { + let all = builtins(); + assert_eq!(all.len(), 5); + for kind in AgentKind::ALL { + if kind == AgentKind::Custom { + continue; + } + let descriptor = builtin(kind.as_str()).expect("a row per built-in"); + assert_eq!(descriptor.id, kind.as_str()); + assert!(descriptor.enabled); + assert_eq!(descriptor.problem(), None, "{kind:?} ships valid"); + } + // Spot-check the shapes spawn relies on. + let claude = builtin("claude").unwrap(); + assert_eq!(claude.program, "claude"); + assert_eq!(claude.model.flag.as_deref(), Some("--model")); + assert_eq!(claude.effort.flag.as_deref(), Some("--effort")); + assert_eq!(claude.resume.flag.as_deref(), Some("--resume")); + assert!(claude.relocation_prompt); + let codex = builtin("codex").unwrap(); + assert_eq!(codex.permissions_flag.as_deref(), Some("--yolo")); + assert_eq!(codex.resume.subcommand.as_deref(), Some("resume")); + assert!(codex.resume.cd); + assert_eq!( + codex.effort.config_key.as_deref(), + Some("model_reasoning_effort") + ); + let cursor = builtin("cursor").unwrap(); + assert_eq!(cursor.program, "cursor-agent"); + assert!(cursor.compose_model_effort); + assert!(!cursor.relocation_prompt); + let pi = builtin("pi").unwrap(); + assert_eq!(pi.resume.flag.as_deref(), Some("--session-id")); + assert_eq!(pi.effort.flag.as_deref(), Some("--thinking")); + let muse = builtin("muse").unwrap(); + assert!(!muse.resumes()); + assert_eq!(muse.hooks, None); + assert!(muse.effort.offered, "muse keeps its reserved Effort row"); + } + + #[test] + fn overrides_apply_field_by_field_with_null_clearing() { + let over: HarnessOverride = serde_json::from_value(serde_json::json!({ + "enabled": false, + "program": "/opt/claude", + "permissions_flag": null, + "resume_flag": "--continue", + "effort_default": "max", + })) + .unwrap(); + let mut descriptor = builtin("claude").unwrap(); + descriptor.apply(&over); + assert!(!descriptor.enabled); + assert_eq!(descriptor.program, "/opt/claude"); + assert_eq!(descriptor.resume.flag.as_deref(), Some("--continue")); + assert_eq!(descriptor.default_effort(), Some("max")); + // Absent fields leave the row alone. + assert_eq!(descriptor.model.flag.as_deref(), Some("--model")); + assert_eq!(descriptor.hooks.as_deref(), Some("claude")); + + // `null` on a tri-state clears the behavior it named. + let clear: HarnessOverride = serde_json::from_value(serde_json::json!({ + "permissions_flag": null, + "hooks": null, + })) + .unwrap(); + let mut codex = builtin("codex").unwrap(); + codex.apply(&clear); + assert_eq!(codex.permissions_flag, None); + let mut claude = builtin("claude").unwrap(); + claude.apply(&clear); + assert_eq!(claude.hooks, None); + assert!(!claude.claude_like()); + } + + #[test] + fn registry_orders_builtins_then_legacy_then_new_ids() { + let mut overrides = BTreeMap::new(); + overrides.insert( + "agy".into(), + HarnessOverride { + program: Clearable::Set("agy".into()), + ..HarnessOverride::default() + }, + ); + overrides.insert( + "claude".into(), + HarnessOverride { + enabled: Some(false), + ..HarnessOverride::default() + }, + ); + let customs = vec![custom("zed")]; + let all = registry(&overrides, &customs); + let ids: Vec<&str> = all.iter().map(|entry| entry.id.as_str()).collect(); + assert_eq!( + ids, + vec!["claude", "codex", "cursor", "pi", "muse", "zed", "agy"] + ); + assert!(!all[0].enabled, "the claude override applied"); + assert_eq!(all[5].program, "agy", "legacy entry converts"); + assert_eq!(all[6].program, "agy", "map-only entry resolves"); + assert!( + !all[6].effort.offered, + "a bare program stays Model-only like a legacy custom" + ); + } + + #[test] + fn map_only_entry_mapping_effort_shows_its_row() { + let mut overrides = BTreeMap::new(); + overrides.insert( + "agy".into(), + HarnessOverride { + program: Clearable::Set("agy".into()), + effort_flag: Clearable::Set("--effort".into()), + efforts: Some(vec!["low".into(), "high".into()]), + ..HarnessOverride::default() + }, + ); + let all = registry(&overrides, &[]); + let agy = all.iter().find(|entry| entry.id == "agy").unwrap(); + assert!(agy.effort.offered); + assert_eq!(agy.problem(), None); + } + + #[test] + fn conflicting_shapes_are_problems() { + let mut descriptor = builtin("codex").unwrap(); + descriptor.resume.flag = Some("--resume".into()); + assert!(descriptor.problem().is_some(), "flag plus subcommand"); + let mut descriptor = builtin("claude").unwrap(); + descriptor.system.prepend_to_first_prompt = true; + assert!(descriptor.problem().is_some(), "flag plus prepend"); + let mut descriptor = builtin("claude").unwrap(); + descriptor.hooks = Some("tmux".into()); + assert!(descriptor.problem().is_some(), "unknown dialect"); + let mut descriptor = builtin("codex").unwrap(); + descriptor.effort.config_flag = None; + assert!(descriptor.problem().is_some(), "config key without its flag"); + let empty_program = HarnessDescriptor { + program: String::new(), + ..builtin("claude").unwrap() + }; + assert!(empty_program.problem().is_some()); + } + + #[test] + fn resolve_names_missing_and_broken_entries() { + let all = registry(&BTreeMap::new(), &[custom("agy")]); + assert_eq!(resolve(&all, AgentKind::Claude, None).unwrap().id, "claude"); + assert_eq!( + resolve(&all, AgentKind::Custom, Some("agy")).unwrap().program, + "agy" + ); + assert!(resolve(&all, AgentKind::Custom, Some("gone")).is_err()); + assert!(resolve(&all, AgentKind::Custom, None).is_err()); + let mut broken = custom("broken"); + broken.program = " ".into(); + let all = registry(&BTreeMap::new(), &[broken]); + assert!(resolve(&all, AgentKind::Custom, Some("broken")).is_err()); + assert_eq!(usable(&all).len(), 5, "the broken entry is hidden"); + } + + #[test] + fn legacy_validation_still_names_its_problems() { + assert!(custom("").problem().is_some()); + assert!(custom("Agy").problem().is_some()); + assert!(custom("claude").problem().is_some()); + assert!(custom("muse").problem().is_some()); + let claude_hooks = CustomHarness { + hooks: Some("claude".into()), + ..custom("agy") + }; + assert_eq!(claude_hooks.problem(), None); + assert_eq!( + claude_hooks.hook_dialect(), + Some(crate::AgentKind::Claude) + ); + } +} diff --git a/crates/nebula-core/src/lib.rs b/crates/nebula-core/src/lib.rs index bf34804..c54a729 100644 --- a/crates/nebula-core/src/lib.rs +++ b/crates/nebula-core/src/lib.rs @@ -2,6 +2,7 @@ pub mod codec; pub mod crashlog; pub mod entities; pub mod env; +pub mod harness; pub mod host; pub mod ids; pub mod mem; @@ -11,5 +12,6 @@ pub mod protocol; pub mod settings; pub use entities::*; +pub use harness::*; pub use ids::*; pub use protocol::*; diff --git a/crates/nebula-core/src/protocol.rs b/crates/nebula-core/src/protocol.rs index a1d7ff3..67ed1db 100644 --- a/crates/nebula-core/src/protocol.rs +++ b/crates/nebula-core/src/protocol.rs @@ -119,6 +119,10 @@ pub enum ClientRequest { worktree: WorktreeId, name: String, kind: AgentKind, + /// Registry id of the custom harness, when `kind` is + /// [`AgentKind::Custom`]. Persisted with the row like `model`. + #[serde(default)] + custom_harness: Option, /// Model the CLI launches with; None = the CLI's own default. model: Option, /// Reasoning effort the CLI launches with; None = the CLI's own default. @@ -166,6 +170,10 @@ pub enum ClientRequest { project: ProjectId, name: String, kind: AgentKind, + /// Registry id of the custom harness, when `kind` is + /// [`AgentKind::Custom`]. Persisted with the row like `model`. + #[serde(default)] + custom_harness: Option, /// Model the CLI launches with; None = the CLI's own default. model: Option, /// Reasoning effort the CLI launches with; None = default. diff --git a/crates/nebula-daemon/src/config.rs b/crates/nebula-daemon/src/config.rs index f9e763d..d613945 100644 --- a/crates/nebula-daemon/src/config.rs +++ b/crates/nebula-daemon/src/config.rs @@ -6,6 +6,7 @@ //! value this build can't read costs only its own key. use serde::Deserialize; +use std::collections::BTreeMap; #[derive(Debug, Clone, Deserialize)] #[serde(default)] @@ -38,6 +39,17 @@ pub struct Config { /// with no such branch at all — the default again, with a warning in /// the daemon log. Read through [`Config::worktree_base_branch`]. pub worktree_base_branch: String, + /// User-defined harnesses from the `custom_harnesses` key, shared with + /// the TUI's picker. The daemon resolves programs, model flags and + /// respawns from this list; entries that fail validation are refused + /// at create time with the reason, never launched. + pub custom_harnesses: Vec, + /// Per-harness deltas over the compiled-in registry (`harnesses` in + /// config.json): repoint a program, rename a flag, switch a harness + /// off, or define a whole new CLI. Merged by + /// [`nebula_core::harness::registry`]; a broken entry refuses its + /// launches with the reason, never the whole daemon. + pub harnesses: BTreeMap, } impl Default for Config { @@ -48,6 +60,8 @@ impl Default for Config { prewarm_sessions: true, session_idle_timeout: DEFAULT_SESSION_IDLE_TIMEOUT.into(), worktree_base_branch: String::new(), + custom_harnesses: Vec::new(), + harnesses: BTreeMap::new(), } } } diff --git a/crates/nebula-daemon/src/hooks/mod.rs b/crates/nebula-daemon/src/hooks/mod.rs index 60ec660..7b42db2 100644 --- a/crates/nebula-daemon/src/hooks/mod.rs +++ b/crates/nebula-daemon/src/hooks/mod.rs @@ -406,7 +406,12 @@ async fn receive_hook( .agent_title_state(&agent_id) .ok() .flatten() - .and_then(|s| s.to_push().map(str::to_string)), + .and_then(|s| { + // Custom rows on the Claude dialect title-push like + // Claude; any other custom row stays quiet (see + // `TitleState::to_push`, which resolves the dialect). + s.to_push().map(str::to_string) + }), HookCli::Codex | HookCli::Cursor | HookCli::Pi => None, }; return ( @@ -477,6 +482,7 @@ mod tests { archived_at: 0, unseen: false, kind: AgentKind::Claude, + custom_harness: None, model: None, effort: None, session_id: None, diff --git a/crates/nebula-daemon/src/pr_scope.rs b/crates/nebula-daemon/src/pr_scope.rs index 9b31b4b..59aa32e 100644 --- a/crates/nebula-daemon/src/pr_scope.rs +++ b/crates/nebula-daemon/src/pr_scope.rs @@ -143,10 +143,11 @@ pub(crate) struct LaunchPrompts { /// Fold a launch rule (the PR rule, the issue rule — see [`combined_rule`]) /// into a spawn's prompts. `initial` is the first prompt the spawn already /// had — a RELOCATION PROMPT or an AGENT PRESET's composed task — and stays -/// where it was; `resumed` tells a Codex / Cursor resume (which needs no -/// rule) from a cold spawn (which opens with it). +/// where it was; `system_append` tells a harness with a system-prompt flag +/// (whose rule rides it) from one without (whose rule opens a cold spawn +/// as its first prompt, and whose resume transcript already holds it). pub(crate) fn launch_prompts( - kind: AgentKind, + system_append: bool, resumed: bool, rule: Option<&str>, initial: Option<&str>, @@ -158,25 +159,24 @@ pub(crate) fn launch_prompts( initial: initial_owned, }; }; - match kind { - AgentKind::Claude | AgentKind::Pi => LaunchPrompts { + if system_append { + return LaunchPrompts { system: Some(rule.to_string()), initial: initial_owned, - }, - // Every other CLI has no system-prompt flag: the rule opens a cold - // spawn as its first prompt, and a resume's transcript already - // holds it. - _ if resumed => LaunchPrompts { + }; + } + if resumed { + return LaunchPrompts { system: None, initial: initial_owned, - }, - _ => LaunchPrompts { - system: None, - initial: Some(match initial { - Some(task) => format!("{rule}\n\n{task}"), - None => rule_as_first_prompt(rule), - }), - }, + }; + } + LaunchPrompts { + system: None, + initial: Some(match initial { + Some(task) => format!("{rule}\n\n{task}"), + None => rule_as_first_prompt(rule), + }), } } @@ -260,6 +260,9 @@ pub(crate) struct CreatePrAgentSpec { pub project: ProjectId, pub name: String, pub kind: AgentKind, + /// Registry id of the custom harness, when `kind` is + /// [`AgentKind::Custom`]. + pub custom_harness: Option, pub model: Option, pub effort: Option, pub auto_title: bool, @@ -279,6 +282,7 @@ impl Daemon { project, name, kind, + custom_harness, model, effort, auto_title, @@ -293,6 +297,7 @@ impl Daemon { worktree: worktree.id, name, kind, + custom_harness, model, effort, auto_title, @@ -341,52 +346,44 @@ mod tests { } #[test] - fn claude_and_pi_take_the_rule_as_a_system_prompt_and_keep_their_first_prompt() { + fn system_prompt_harnesses_take_the_rule_as_a_system_prompt_and_keep_their_first_prompt() { let scope = scope(None); let text = rule(&scope); - for kind in [AgentKind::Claude, AgentKind::Pi] { - for resumed in [false, true] { - let prompts = launch_prompts(kind, resumed, Some(&text), Some("relocated")); - assert_eq!( - prompts.system.as_deref(), - Some(rule(&scope).as_str()), - "{kind:?}" - ); - assert_eq!(prompts.initial.as_deref(), Some("relocated"), "{kind:?}"); - } + for resumed in [false, true] { + let prompts = launch_prompts(true, resumed, Some(&text), Some("relocated")); + assert_eq!(prompts.system.as_deref(), Some(rule(&scope).as_str())); + assert_eq!(prompts.initial.as_deref(), Some("relocated")); } } #[test] - fn codex_and_cursor_open_a_cold_spawn_with_the_rule_and_resume_without_it() { + fn harnesses_without_a_system_prompt_flag_open_cold_with_the_rule() { let scope = scope(None); let text = rule(&scope); - for kind in [AgentKind::Codex, AgentKind::Cursor] { - let cold = launch_prompts(kind, false, Some(&text), None); - assert_eq!(cold.system, None, "{kind:?} has no system-prompt flag"); - let first = cold.initial.expect("the rule is the first prompt"); - assert!(first.starts_with(&rule(&scope)), "{first}"); - assert!(first.contains("wait for the user's request"), "{first}"); - - let with_task = launch_prompts(kind, false, Some(&text), Some("fix the tests")); - assert_eq!( - with_task.initial.as_deref(), - Some(format!("{}\n\nfix the tests", rule(&scope)).as_str()) - ); + let cold = launch_prompts(false, false, Some(&text), None); + assert_eq!(cold.system, None, "no system-prompt flag"); + let first = cold.initial.expect("the rule is the first prompt"); + assert!(first.starts_with(&rule(&scope)), "{first}"); + assert!(first.contains("wait for the user's request"), "{first}"); - let resumed = launch_prompts(kind, true, Some(&text), None); - assert_eq!( - resumed, - LaunchPrompts::default(), - "the transcript carries the rule through a resume" - ); - } + let with_task = launch_prompts(false, false, Some(&text), Some("fix the tests")); + assert_eq!( + with_task.initial.as_deref(), + Some(format!("{}\n\nfix the tests", rule(&scope)).as_str()) + ); + + let resumed = launch_prompts(false, true, Some(&text), None); + assert_eq!( + resumed, + LaunchPrompts::default(), + "the transcript carries the rule through a resume" + ); } #[test] fn no_scope_changes_nothing() { - for kind in AgentKind::ALL { - let prompts = launch_prompts(kind, false, None, Some("task")); + for system_append in [false, true] { + let prompts = launch_prompts(system_append, false, None, Some("task")); assert_eq!( prompts, LaunchPrompts { diff --git a/crates/nebula-daemon/src/prompt_history.rs b/crates/nebula-daemon/src/prompt_history.rs index 9b49ca1..51434a5 100644 --- a/crates/nebula-daemon/src/prompt_history.rs +++ b/crates/nebula-daemon/src/prompt_history.rs @@ -168,6 +168,7 @@ mod tests { archived_at: 0, unseen: false, kind: AgentKind::Claude, + custom_harness: None, model: None, effort: None, session_id: None, diff --git a/crates/nebula-daemon/src/registry.rs b/crates/nebula-daemon/src/registry.rs index 33b6d06..0c16b0c 100644 --- a/crates/nebula-daemon/src/registry.rs +++ b/crates/nebula-daemon/src/registry.rs @@ -76,6 +76,9 @@ pub(crate) struct CreateAgentSpec { pub worktree: WorktreeId, pub name: String, pub kind: AgentKind, + /// Registry id of the custom harness, when `kind` is + /// [`AgentKind::Custom`]. + pub custom_harness: Option, pub model: Option, pub effort: Option, pub auto_title: bool, @@ -135,7 +138,7 @@ pub struct Daemon { prewarmed: Mutex>, /// Cached `command -v` results per CLI so a missing binary doesn't get /// re-probed (login shell spawn) on every prewarm request. - cli_probes: Mutex>, + cli_probes: Mutex>, /// How many client connections are attached per session — a session /// with attachments (and its whole worktree) is "in view" and exempt /// from idle reaping. @@ -1052,6 +1055,7 @@ impl Daemon { worktree: worktree_id, name, kind, + custom_harness, model, effort, auto_title, @@ -1091,6 +1095,12 @@ impl Daemon { Some(url) => Some(crate::pr_scope::validate_issue_url(&url)?), None => None, }; + // Every harness resolves against the current registry before + // anything spawns: a missing or broken entry refuses the create + // with its reason, and a deleted entry breaks respawns the same + // way at boot. + let harness = resolve_harness(kind, custom_harness.as_deref())?; + let program = harness.program.trim().to_string(); let worktree = self .store .get_worktree(&worktree_id)? @@ -1110,8 +1120,8 @@ impl Daemon { // the CLI runs. Without this, a missing CLI still "succeeds" — the // login shell prints `command not found` into a PTY that dies at // once, leaving a dead row that looks identical to a fresh one. - if adopted.is_none() && !self.cli_available_for_create(kind).await { - bail!("{}", cli_missing_message(kind)); + if adopted.is_none() && !self.cli_available_for_create(&program).await { + bail!("{}", cli_missing_message(&program)); } let agent = Agent { id: adopted @@ -1129,6 +1139,11 @@ impl Daemon { archived_at: 0, unseen: false, kind, + custom_harness: custom_harness + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(str::to_string), model, effort, session_id: None, @@ -1200,6 +1215,12 @@ impl Daemon { if !crate::config::Config::load().prewarm_agents { return Ok(()); } + if kind == AgentKind::Custom { + // No warm spares for custom harnesses: the pool is keyed by + // kind alone and a spare booted for one entry must never be + // adopted by another. Custom creates stay cold. + return Ok(()); + } let Some(worktree) = self.store.get_worktree(worktree_id)? else { return Ok(()); }; @@ -1224,7 +1245,7 @@ impl Daemon { if let Some(old) = stale { self.kill_session(&SessionRef::Agent(old.agent_id)); } - if !self.cli_available(kind).await { + if !self.cli_available(kind.cli_program()).await { tracing::debug!(kind = kind.as_str(), "prewarm skipped: CLI not installed"); return Ok(()); } @@ -1237,6 +1258,7 @@ impl Daemon { archived_at: 0, unseen: false, kind, + custom_harness: None, model: model.clone(), effort: effort.clone(), session_id: None, @@ -1371,11 +1393,12 @@ impl Daemon { } } - /// Is the kind's CLI on the user's PATH (as their login shell sees it)? - /// Cached: hits for an hour, misses for a minute so a just-installed CLI - /// gets picked up quickly. Probe trouble (timeout, spawn error) fails - /// open — a doomed warm spawn is still graceful. - async fn cli_available(&self, kind: AgentKind) -> bool { + /// Is the harness's CLI on the user's PATH (as their login shell sees + /// it)? Cached by program: hits for an hour, misses for a minute so a + /// just-installed CLI gets picked up quickly. Probe trouble (timeout, + /// spawn error) fails open — a doomed warm spawn is still graceful. + /// Custom harnesses pass their entry's program; built-ins their CLI. + async fn cli_available(&self, program: &str) -> bool { if std::env::var(env::AGENT_CMD).is_ok() { return true; // test override is spawned verbatim } @@ -1383,21 +1406,31 @@ impl Daemon { const FAIL_TTL: Duration = Duration::from_secs(60); { let probes = self.cli_probes.lock().unwrap(); - if let Some((ok, at)) = probes.get(&kind) { + if let Some((ok, at)) = probes.get(program) { if at.elapsed() < if *ok { OK_TTL } else { FAIL_TTL } { return *ok; } } } - self.probe_cli(kind).await + self.probe_cli(program).await } - /// Fill the availability cache for every kind at boot, off the request - /// loop. Without it the first CreateAgent of a session pays a full - /// login-shell probe (~1s with a heavy ~/.zshrc) before it can answer. + /// Fill the availability cache for every harness at boot, off the + /// request loop. Without it the first CreateAgent of a session pays a + /// full login-shell probe (~1s with a heavy ~/.zshrc) before it can + /// answer. Custom entries resolve against the current config; a bare + /// `Custom` kind never reaches the probe — it has no program. pub async fn warm_cli_probes(self: &Arc) { - for kind in AgentKind::ALL { - self.cli_available(kind).await; + // One probe per launchable program in the effective registry, so + // a repointed program warms the binary that actually launches. + let mut programs = Vec::new(); + for entry in harness_registry() { + if entry.problem().is_none() && !programs.contains(&entry.program) { + programs.push(entry.program.clone()); + } + } + for program in programs { + self.cli_available(program.trim()).await; } } @@ -1405,13 +1438,13 @@ impl Daemon { /// A cached *hit* is trusted; a cached *miss* is re-probed, so someone who /// installs the CLI and immediately retries isn't told for another minute /// that it's missing. Misses are rare, so this costs nothing in practice. - async fn cli_available_for_create(&self, kind: AgentKind) -> bool { - self.cli_available(kind).await || self.probe_cli(kind).await + async fn cli_available_for_create(&self, program: &str) -> bool { + self.cli_available(program).await || self.probe_cli(program).await } /// Uncached `command -v` through the user's login shell; caches the answer. - async fn probe_cli(&self, kind: AgentKind) -> bool { - let check = format!("command -v '{}' >/dev/null 2>&1", kind.cli_program()); + async fn probe_cli(&self, program: &str) -> bool { + let check = cli_probe_line(program); let mut probe = tokio::process::Command::new(user_shell()); probe .args(LOGIN_SHELL_ARGS) @@ -1438,7 +1471,7 @@ impl Daemon { self.cli_probes .lock() .unwrap() - .insert(kind, (ok, Instant::now())); + .insert(program.to_string(), (ok, Instant::now())); ok } _ => true, @@ -1627,7 +1660,12 @@ impl Daemon { tracing::info!(agent = %id, to = %target.branch, "relocating session into its worktree"); self.kill_session(&sref); self.last_cwd.lock().unwrap().remove(id); - let prompt = relocation_prompt(agent.kind, &target); + // A row whose entry went missing since still relocates; the boot + // itself refuses with the entry's reason, so the notice degrades + // to none rather than failing the move. + let prompt = resolve_harness(agent.kind, agent.custom_harness.as_deref()) + .map(|harness| relocation_prompt(harness.relocation_prompt, &target)) + .unwrap_or(None); if let Err(e) = self.spawn_agent_session_with( &agent, &target, @@ -2278,26 +2316,37 @@ impl Daemon { // Whatever spawns this agent, it runs in `worktree` from here: a // relocation still pending for it has been overtaken. self.pending_moves.lock().unwrap().remove(&agent.id); + // Every row resolves its registry descriptor once, up front: a row + // whose entry was deleted or broken since refuses the boot with + // its reason rather than launching the wrong CLI, and the same + // descriptor picks the hook dialect below. + let harness = resolve_harness(agent.kind, agent.custom_harness.as_deref())?; // Managed status hooks; a failure here degrades to "no status - // updates", never blocks the spawn. - let install_result = match agent.kind { - AgentKind::Claude => hooks::installer::install_claude_hooks(&worktree.path), + // updates", never blocks the spawn. The dialect is data: a custom + // harness naming one reports status, prompts and permission waits + // exactly like that harness, and a harness with none runs + // hookless (process-based status until a dialect is mapped). + let install_result = match harness.hook_dialect() { + Some(AgentKind::Claude) => hooks::installer::install_claude_hooks(&worktree.path), // Codex's hooks live in its home, not the worktree, so one // trust approval covers every worktree (see installer docs); // any per-worktree copy an older nebula left is pruned. - AgentKind::Codex => { + Some(AgentKind::Codex) => { hooks::installer::install_codex_hooks(&hooks::installer::codex_home()) .and_then(|()| hooks::installer::prune_codex_worktree_hooks(&worktree.path)) } // Cursor also gets the managed auto-title project rule — its // hook dialect has no context-injection channel. - AgentKind::Cursor => hooks::installer::install_cursor_hooks(&worktree.path) + Some(AgentKind::Cursor) => hooks::installer::install_cursor_hooks(&worktree.path) .and_then(|()| hooks::installer::install_cursor_title_rule(&worktree.path)), // Pi runs TypeScript extensions, not shell hooks: one managed // extension in its global agent dir (loaded without the trust // prompt a worktree-local `.pi/extensions/` would raise) serves // every worktree. - AgentKind::Pi => hooks::pi_extension::install(&hooks::pi_extension::pi_agent_dir()), + Some(AgentKind::Pi) => { + hooks::pi_extension::install(&hooks::pi_extension::pi_agent_dir()) + } + _ => Ok(()), }; if let Err(e) = install_result { tracing::warn!(error = %e, cwd = %worktree.path.display(), "hook install failed"); @@ -2362,20 +2411,21 @@ impl Daemon { }); let rule = crate::pr_scope::combined_rule(scope.as_ref(), issue_scope.as_ref()); let prompts = crate::pr_scope::launch_prompts( - agent.kind, + harness.system.append_flag.is_some(), agent.session_id.is_some(), rule.as_deref(), initial_prompt, ); let (program, args, resumed) = match cloud_task { Some(task) => claude_cloud_spawn_command( + &harness, task, agent.model.as_deref(), agent.effort.as_deref(), cmd_override.as_deref(), ), None => agent_spawn_command_with( - agent.kind, + &harness, agent.session_id.as_deref(), Some(&worktree.path), agent.model.as_deref(), @@ -2760,7 +2810,8 @@ fn claude_transcript_exists(roots: &[PathBuf], session_id: &str) -> Option /// /// The plain shape, as every restart/resume spawns it: booted in /// `TEST_CWD`, no initial prompt, guidance on. Tests assert against this; -/// the daemon calls the full form. +/// the daemon calls the full form. The descriptor resolves from a pinned +/// registry so tests never touch the user's config. #[cfg(test)] fn agent_spawn_command( kind: AgentKind, @@ -2769,8 +2820,10 @@ fn agent_spawn_command( effort: Option<&str>, cmd_override: Option<&str>, ) -> (String, Vec, bool) { + let all = test_registry(); + let harness = test_harness(&all, kind); agent_spawn_command_with( - kind, + &harness, session_id, Some(Path::new(TEST_CWD)), model, @@ -2782,6 +2835,34 @@ fn agent_spawn_command( ) } +/// A pinned registry for spawn tests: the compiled-in rows, no overrides, +/// no legacy entries — what a fresh install launches. +#[cfg(test)] +fn test_registry() -> Vec { + harness_registry_in( + &std::collections::BTreeMap::new(), + &[], + ) +} + +/// The pinned descriptor `kind` launches as in spawn tests. +#[cfg(test)] +fn test_harness( + all: &[nebula_core::harness::HarnessDescriptor], + kind: AgentKind, +) -> nebula_core::harness::HarnessDescriptor { + resolve_harness_in(kind, None, all).expect("built-ins resolve from a pinned registry") +} + +/// The pinned descriptor for a legacy custom entry in spawn tests. +#[cfg(test)] +fn test_custom_harness( + all: &[nebula_core::harness::HarnessDescriptor], + id: &str, +) -> nebula_core::harness::HarnessDescriptor { + resolve_harness_in(AgentKind::Custom, Some(id), all).expect("the pinned entry resolves") +} + /// What nebula appends to Claude's system prompt: how to take a "do this /// in a worktree" request through nebula (`Daemon::enter_worktree`) instead /// of Claude's own EnterWorktree tool, whose checkout lands under @@ -2811,27 +2892,29 @@ command fails, report the error and carry on in the current checkout."; /// and `pi --session-id ""`. Whether `cursor-agent --resume /// [prompt...]` submits one is not, so a relocated cursor session /// resumes silent and waits for the user. -fn relocation_prompt(kind: AgentKind, worktree: &Worktree) -> Option { - match kind { - AgentKind::Claude | AgentKind::Codex | AgentKind::Pi => Some(format!( +fn relocation_prompt(relocate: bool, worktree: &Worktree) -> Option { + relocate.then(|| { + format!( "[nebula] This session now runs inside the worktree `{}` at {} — your working \ directory is that checkout. Continue the user's most recent request there.", worktree.branch, worktree.path.display() - )), - AgentKind::Cursor => None, - } + ) + }) } /// The checkout the test wrapper boots every spawn in. #[cfg(test)] const TEST_CWD: &str = "/nebula-test/p-feat"; -// Nine positional knobs are two over clippy's line; the callers are the two -// thin wrappers above and the tests, so a builder would only add ceremony. +// Nine positional knobs are two over clippy's line; the callers are the +// two thin wrappers above and the tests, so a builder would only add +// ceremony. Every behavior comes off `harness` — the registry descriptor +// the launch resolved — never off the kind: a repointed program, a renamed +// flag or a whole new CLI flows through here with no new arms. #[allow(clippy::too_many_arguments)] fn agent_spawn_command_with( - kind: AgentKind, + harness: &nebula_core::harness::HarnessDescriptor, session_id: Option<&str>, cwd: Option<&Path>, model: Option<&str>, @@ -2844,91 +2927,95 @@ fn agent_spawn_command_with( if let Some(cmd) = cmd_override { let mut parts = cmd.split_whitespace().map(String::from).collect::>(); if parts.is_empty() { - parts.push(kind.cli_program().into()); + parts.push(harness.program.trim().to_string()); } let program = parts.remove(0); return (program, parts, false); } - let program = kind.cli_program().to_string(); - let (mut args, resumed) = match (kind, session_id) { - (AgentKind::Claude, Some(sid)) => (vec!["--resume".to_string(), sid.to_string()], true), - (AgentKind::Codex, Some(sid)) => { - let mut args = vec!["resume".to_string(), sid.to_string()]; - if let Some(cwd) = cwd { - args.extend(["--cd".to_string(), cwd.to_string_lossy().into_owned()]); + let program = harness.program.trim().to_string(); + // Resume: a flag (`--resume `), a positional subcommand + // (`resume `, with `--cd`), or nothing — a stored id with no + // resume mapping is ignored and the CLI boots fresh. + let (mut args, resumed) = match (&harness.resume.flag, &harness.resume.subcommand, session_id) { + (Some(flag), _, Some(sid)) => (vec![flag.clone(), sid.to_string()], true), + (None, Some(subcommand), Some(sid)) => { + let mut args = vec![subcommand.clone(), sid.to_string()]; + if harness.resume.cd { + if let Some(cwd) = cwd { + args.extend(["--cd".to_string(), cwd.to_string_lossy().into_owned()]); + } } (args, true) } - (AgentKind::Cursor, Some(sid)) => (vec!["--resume".to_string(), sid.to_string()], true), - (AgentKind::Pi, Some(sid)) => (vec!["--session-id".to_string(), sid.to_string()], true), - (_, None) => (Vec::new(), false), + _ => (Vec::new(), false), }; - match kind { - AgentKind::Codex => args.push("--yolo".to_string()), - AgentKind::Cursor => args.push("--force".to_string()), - AgentKind::Claude | AgentKind::Pi => {} - } - // Claude, codex and pi spell the model flag the same way, and it - // follows the skip-permissions flag (`codex --yolo --model …`). Cursor - // composes its own below: family and effort become one id. - if let (Some(m), AgentKind::Claude | AgentKind::Codex | AgentKind::Pi) = (model, kind) { - args.extend(["--model".to_string(), m.to_string()]); - } - match kind { - AgentKind::Claude => { - if let Some(e) = effort { - args.extend(["--effort".to_string(), e.to_string()]); - } - push_system_prompt(&mut args, guidance, additional_system_prompt); - if let Some(p) = initial_prompt { - args.push(p.to_string()); + if let Some(flag) = harness.permissions_flag.as_deref() { + args.push(flag.to_string()); + } + // The model rides its flag — composed with the effort into one id for + // Cursor's family-suffix shape (`claude-opus-5` + `high`: the TUI only + // sends an effort the family ships; an effort without a family has + // nothing to hang off and is dropped). + if let (Some(m), Some(flag)) = (model, harness.model.flag.as_deref()) { + let id = match (harness.compose_model_effort, effort) { + (true, Some(e)) => format!("{m}-{e}"), + _ => m.to_string(), + }; + args.extend([flag.to_string(), id]); + } + // The effort rides its flag, Codex's `-c key=value` pair, or nothing + // (composed above, or unmapped and dropped like before). + if let Some(e) = effort { + if !harness.compose_model_effort { + if let Some(flag) = harness.effort.flag.as_deref() { + args.extend([flag.to_string(), e.to_string()]); + } else if let (Some(flag), Some(key)) = ( + harness.effort.config_flag.as_deref(), + harness.effort.config_key.as_deref(), + ) { + args.extend([flag.to_string(), format!("{key}={e}")]); } } - AgentKind::Pi => { - // pi's reasoning knob is `--thinking `. - if let Some(e) = effort { - args.extend(["--thinking".to_string(), e.to_string()]); - } - push_system_prompt(&mut args, guidance, additional_system_prompt); - // `pi [options] [--] [@files...] [messages...]` — trailing. - if let Some(p) = initial_prompt { - args.push(p.to_string()); - } + } + // Guidance (worktree rules, then the PR scope) rides the + // system-prompt flag where one is mapped, folds into the first prompt + // where prepend is, and is dropped where neither is. + let mut initial_prompt = initial_prompt.map(str::to_string); + if let Some(flag) = harness.system.append_flag.as_deref() { + push_system_prompt(&mut args, flag, guidance, additional_system_prompt); + } else if harness.system.prepend_to_first_prompt { + let mut first = Vec::new(); + if guidance { + first.push(CLAUDE_WORKTREE_GUIDANCE.to_string()); + first.push(crate::sibling::CLAUDE_SPAWN_GUIDANCE.to_string()); + first.push(crate::open_files::CLAUDE_OPEN_GUIDANCE.to_string()); } - AgentKind::Codex => { - if let Some(e) = effort { - args.extend(["-c".to_string(), format!("model_reasoning_effort={e}")]); - } - // `codex [OPTIONS] [PROMPT]` — the trailing positional. - if let Some(p) = initial_prompt { - args.push(p.to_string()); - } + if let Some(prompt) = additional_system_prompt { + first.push(prompt.to_string()); } - AgentKind::Cursor => { - // `--model -`: the TUI keeps the family and the - // effort suffix apart (`claude-opus-5` + `high`) and only sends - // an effort the family ships; an effort without a family has - // nothing to hang off and is dropped. - if let Some(m) = model { - let id = match effort { - Some(e) => format!("{m}-{e}"), - None => m.to_string(), - }; - args.extend(["--model".to_string(), id]); - } - // `cursor-agent [options] [prompt...]` — the trailing positional. - if let Some(p) = initial_prompt { - args.push(p.to_string()); - } + if !first.is_empty() { + let head = first.join("\n\n"); + initial_prompt = Some(match initial_prompt { + Some(task) => format!("{head}\n\n{task}"), + None => head, + }); } } + // The starting prompt rides trailing, like every CLI's positional. + if let Some(p) = initial_prompt { + args.push(p); + } (program, args, resumed) } -/// One `--append-system-prompt` carrying nebula's guidance (worktree, spawn, -/// then open) and whatever else the launch adds (the PR scope), for the CLIs -/// that take the flag — Claude and pi. -fn push_system_prompt(args: &mut Vec, guidance: bool, additional: Option<&str>) { +/// One system-prompt flag carrying nebula's guidance (worktree, spawn, +/// then open) and whatever else the launch adds (the PR scope). +fn push_system_prompt( + args: &mut Vec, + flag: &str, + guidance: bool, + additional: Option<&str>, +) { let mut system_prompt = Vec::new(); if guidance { system_prompt.push(CLAUDE_WORKTREE_GUIDANCE); @@ -2939,10 +3026,7 @@ fn push_system_prompt(args: &mut Vec, guidance: bool, additional: Option system_prompt.push(prompt); } if !system_prompt.is_empty() { - args.extend([ - "--append-system-prompt".to_string(), - system_prompt.join("\n\n"), - ]); + args.extend([flag.to_string(), system_prompt.join("\n\n")]); } } @@ -3002,21 +3086,14 @@ fn validate_cloud_text(raw: &str, what: &str) -> Result { /// (`--cloud=`): the flag takes an *optional* value, so a separate /// argv item that starts with `--` would be parsed as another Claude flag. fn claude_cloud_spawn_command( + harness: &nebula_core::harness::HarnessDescriptor, task: &str, model: Option<&str>, effort: Option<&str>, cmd_override: Option<&str>, ) -> (String, Vec, bool) { let (program, mut args, resumed) = agent_spawn_command_with( - AgentKind::Claude, - None, - None, - model, - effort, - cmd_override, - None, - None, - false, + harness, None, None, model, effort, cmd_override, None, None, false, ); if cmd_override.is_none() { args.insert(0, format!("--cloud={task}")); @@ -3110,11 +3187,54 @@ fn user_shell() -> String { /// the daemon runs with its own inherited PATH), agent CLIs are spawned /// through the user's login shell, so a fresh install is picked up on the /// next try with no daemon restart. -fn cli_missing_message(kind: AgentKind) -> String { - format!( - "{} was not found on your PATH — install it, then try again.", - kind.cli_program() - ) +fn cli_missing_message(program: &str) -> String { + format!("{program} was not found on your PATH — install it, then try again.") +} + +/// The effective harness registry from the current config: the +/// compiled-in known harnesses with the `harnesses` map applied, then the +/// legacy `custom_harnesses` list, then map-only new ids. Every launch, +/// resume and hook install resolves through here, so a config edit (not a +/// rebuild) is what adds a CLI. +fn harness_registry() -> Vec { + let config = crate::config::Config::load(); + harness_registry_in(&config.harnesses, &config.custom_harnesses) +} + +/// [`harness_registry`] against an explicit config, so tests can pin the +/// registry without touching the user's files. +fn harness_registry_in( + overrides: &std::collections::BTreeMap, + customs: &[nebula_core::harness::CustomHarness], +) -> Vec { + nebula_core::harness::registry(overrides, customs) +} + +/// The descriptor a launch or row runs as: built-ins by kind, customs by +/// registry id. A missing id, or a broken entry, refuses the caller with +/// its reason before anything spawns. An `enabled` switch gates the +/// picker, never an existing row — a harness switched off after its +/// sessions were created keeps running them. +fn resolve_harness( + kind: AgentKind, + id: Option<&str>, +) -> Result { + resolve_harness_in(kind, id, &harness_registry()) +} + +/// [`resolve_harness`] against an explicit registry, so tests can pin +/// entries without touching the user's config. +fn resolve_harness_in( + kind: AgentKind, + id: Option<&str>, + all: &[nebula_core::harness::HarnessDescriptor], +) -> Result { + if kind == AgentKind::Custom && id.map(str::trim).filter(|id| !id.is_empty()).is_none() { + bail!("custom harness launch is missing its registry id"); + } + nebula_core::harness::resolve(all, kind, id) + .map(|descriptor| descriptor.clone()) + .map_err(anyhow::Error::msg) } /// Wrap `program args…` in a login + interactive shell (`$SHELL -l -i -c @@ -3195,17 +3315,27 @@ fn shell_quote(arg: &str) -> String { format!("'{}'", arg.replace('\'', "'\\''")) } +/// The login-shell line [`Daemon::probe_cli`] runs to ask whether +/// `program` resolves. The word is single-quoted through [`shell_quote`]: +/// a `harnesses` entry in config.json can name any string, and pasted in +/// bare a quote would close the word and run the rest as a command — at +/// daemon boot, since [`Daemon::warm_cli_probes`] asks for every entry. +/// Built-in names come out exactly as they always did (`'claude'`). +fn cli_probe_line(program: &str) -> String { + format!("command -v {} >/dev/null 2>&1", shell_quote(program)) +} + #[cfg(test)] mod tests { use super::*; /// Claude argv: `args`, then nebula's appended guidance (worktree and /// spawn, one `--append-system-prompt`). - fn guided(args: &[&str]) -> Vec { + fn guided(flag: &str, args: &[&str]) -> Vec { args.iter() .map(|s| s.to_string()) .chain([ - "--append-system-prompt".to_string(), + flag.to_string(), [ CLAUDE_WORKTREE_GUIDANCE, crate::sibling::CLAUDE_SPAWN_GUIDANCE, @@ -3245,7 +3375,7 @@ mod tests { // Fresh sessions: bare CLI (Claude plus its system-prompt guidance). assert_eq!( agent_spawn_command(AgentKind::Claude, None, None, None, None), - ("claude".into(), guided(&[]), false) + ("claude".into(), guided("--append-system-prompt", &[]), false) ); // Codex/cursor always run in skip-permissions mode. assert_eq!( @@ -3261,18 +3391,28 @@ mod tests { // Claude (it has the system-prompt flag). assert_eq!( agent_spawn_command(AgentKind::Pi, None, None, None, None), - ("pi".into(), guided(&[]), false) + ("pi".into(), guided("--append-system-prompt", &[]), false) ); // Pi resumes by exact id — one that is missing is created, so a // relocated session's new cwd never dies on a stale id. assert_eq!( agent_spawn_command(AgentKind::Pi, Some("sid-4"), None, None, None), - ("pi".into(), guided(&["--session-id", "sid-4"]), true) + ("pi".into(), guided("--append-system-prompt", &["--session-id", "sid-4"]), true) + ); + // Muse boots bare and fresh: no resume flag is mapped yet, so a + // stored session id is ignored rather than sent. + assert_eq!( + agent_spawn_command(AgentKind::Muse, None, None, None, None), + ("muse".into(), vec![], false) + ); + assert_eq!( + agent_spawn_command(AgentKind::Muse, Some("sid-9"), None, None, None), + ("muse".into(), vec![], false) ); // Claude resumes with a flag; codex with a subcommand (order matters). assert_eq!( agent_spawn_command(AgentKind::Claude, Some("sid-1"), None, None, None), - ("claude".into(), guided(&["--resume", "sid-1"]), true) + ("claude".into(), guided("--append-system-prompt", &["--resume", "sid-1"]), true) ); // Skip-permissions flags trail the resume args. A codex resume is // told its checkout (`--cd`): without it codex reopens the session @@ -3328,13 +3468,13 @@ mod tests { agent_spawn_command(AgentKind::Claude, None, Some("opus"), Some("high"), None), ( "claude".into(), - guided(&["--model", "opus", "--effort", "high"]), + guided("--append-system-prompt", &["--model", "opus", "--effort", "high"]), false ) ); assert_eq!( agent_spawn_command(AgentKind::Claude, None, None, Some("max"), None), - ("claude".into(), guided(&["--effort", "max"]), false) + ("claude".into(), guided("--append-system-prompt", &["--effort", "max"]), false) ); // Codex takes --model plus a config override for effort, after --yolo. assert_eq!( @@ -3357,13 +3497,23 @@ mod tests { agent_spawn_command(AgentKind::Pi, None, Some("sonnet"), Some("high"), None), ( "pi".into(), - guided(&["--model", "sonnet", "--thinking", "high"]), + guided("--append-system-prompt", &["--model", "sonnet", "--thinking", "high"]), false ) ); assert_eq!( agent_spawn_command(AgentKind::Pi, None, None, Some("off"), None), - ("pi".into(), guided(&["--thinking", "off"]), false) + ("pi".into(), guided("--append-system-prompt", &["--thinking", "off"]), false) + ); + // Muse takes `--model` verbatim and no effort flag yet: effort is + // dropped, never sent. + assert_eq!( + agent_spawn_command(AgentKind::Muse, None, Some("spark"), Some("high"), None), + ( + "muse".into(), + vec!["--model".to_string(), "spark".to_string()], + false + ) ); // Resume keeps the model/effort flags (a fallback fresh spawn needs // them, and the CLIs accept them alongside resume). @@ -3371,7 +3521,7 @@ mod tests { agent_spawn_command(AgentKind::Claude, Some("sid"), Some("sonnet"), None, None), ( "claude".into(), - guided(&["--resume", "sid", "--model", "sonnet"]), + guided("--append-system-prompt", &["--resume", "sid", "--model", "sonnet"]), true ) ); @@ -3421,11 +3571,230 @@ mod tests { ); } + #[test] + fn custom_spawn_uses_entry_program_and_model_flag() { + // Custom entries launch with their own program and model flag; a + // stored session id is ignored (fresh boot) and effort is dropped. + let agy = nebula_core::harness::CustomHarness { + id: "agy".into(), + label: "Agy".into(), + program: "agy".into(), + enabled: true, + model: "default".into(), + model_flag: "--model".into(), + hooks: None, + }; + let all = harness_registry_in(&std::collections::BTreeMap::new(), &[agy.clone()]); + let harness = test_custom_harness(&all, "agy"); + let (program, args, resumed) = agent_spawn_command_with( + &harness, + Some("sid-1"), + Some(Path::new(TEST_CWD)), + Some("big-1"), + Some("high"), + None, + Some("do it"), + None, + true, + ); + assert_eq!(program, "agy"); + assert_eq!(args, vec!["--model", "big-1", "do it"]); + assert!(!resumed); + // A custom model flag spelling is honored verbatim. + let gemini = nebula_core::harness::CustomHarness { + model_flag: "-m".into(), + ..agy.clone() + }; + let all = harness_registry_in(&std::collections::BTreeMap::new(), &[gemini]); + let harness = test_custom_harness(&all, "agy"); + let (_, args, _) = agent_spawn_command_with( + &harness, + None, + Some(Path::new(TEST_CWD)), + Some("flash"), + None, + None, + None, + None, + true, + ); + assert_eq!(args, vec!["-m", "flash"]); + } + + #[test] + fn custom_spawn_honors_map_deltas_for_resume_and_hooks() { + // A legacy entry gains a resume flag and an effort flag purely + // through the `harnesses` map — no code change, no new shape. + use nebula_core::harness::{Clearable, HarnessOverride}; + let agy = nebula_core::harness::CustomHarness { + id: "agy".into(), + label: "Agy".into(), + program: "agy".into(), + enabled: true, + model: "default".into(), + model_flag: "--model".into(), + hooks: None, + }; + let mut overrides = std::collections::BTreeMap::new(); + overrides.insert( + "agy".into(), + HarnessOverride { + resume_flag: Clearable::Set("--resume".into()), + effort_flag: Clearable::Set("--effort".into()), + effort_offered: Some(true), + hooks: Clearable::Set("claude".into()), + ..HarnessOverride::default() + }, + ); + let all = harness_registry_in(&overrides, &[agy]); + let harness = test_custom_harness(&all, "agy"); + assert_eq!( + harness.hook_dialect(), + Some(AgentKind::Claude), + "the dialect installs Claude hooks for a third-party CLI" + ); + let (program, args, resumed) = agent_spawn_command_with( + &harness, + Some("sid-1"), + Some(Path::new(TEST_CWD)), + Some("big-1"), + Some("high"), + None, + Some("do it"), + None, + true, + ); + assert_eq!(program, "agy"); + assert_eq!( + args, + vec!["--resume", "sid-1", "--model", "big-1", "--effort", "high", "do it"] + ); + assert!(resumed); + } + + /// A third-party CLI with every row mapped — shaped like xAI's + /// `grok` (`--model`, `--reasoning-effort`, `--resume `, + /// `--rules` appending to the system prompt, trailing prompt) — + /// spawns, resumes and carries guidance with config alone. + #[test] + fn third_party_harness_with_all_rows_mapped_spawns_and_resumes() { + use nebula_core::harness::{Clearable, HarnessOverride}; + let mut overrides = std::collections::BTreeMap::new(); + overrides.insert( + "grok".into(), + HarnessOverride { + program: Clearable::Set("grok".into()), + model_flag: Clearable::Set("--model".into()), + effort_flag: Clearable::Set("--reasoning-effort".into()), + effort_offered: Some(true), + resume_flag: Clearable::Set("--resume".into()), + system_append_flag: Clearable::Set("--rules".into()), + ..HarnessOverride::default() + }, + ); + let all = harness_registry_in(&overrides, &[]); + let grok = test_custom_harness(&all, "grok"); + assert_eq!(grok.problem(), None); + + // Fresh boot: model, effort, guidance and prompt in order, no + // permissions flag the entry never named. + let (program, args, resumed) = agent_spawn_command_with( + &grok, + None, + Some(Path::new(TEST_CWD)), + Some("grok-code"), + Some("high"), + None, + Some("fix auth"), + None, + true, + ); + assert_eq!(program, "grok"); + assert!(!resumed); + let mut expected = vec![ + "--model".to_string(), + "grok-code".to_string(), + "--reasoning-effort".to_string(), + "high".to_string(), + ]; + expected.append(&mut guided("--rules", &[])); + expected.push("fix auth".into()); + assert_eq!(args, expected); + + // Resume: the stored id rides the mapped flag. + let (_, args, resumed) = agent_spawn_command_with( + &grok, + Some("sid-9"), + Some(Path::new(TEST_CWD)), + None, + None, + None, + None, + None, + false, + ); + assert!(resumed); + assert_eq!(args, vec!["--resume", "sid-9"]); + } + + #[test] + fn harness_resolve_names_missing_unknown_and_broken_entries() { + use nebula_core::harness::CustomHarness; + let agy = CustomHarness { + id: "agy".into(), + label: String::new(), + program: "agy".into(), + enabled: true, + model: "default".into(), + model_flag: "--model".into(), + hooks: None, + }; + let all = harness_registry_in(&std::collections::BTreeMap::new(), &[agy.clone()]); + // Built-ins resolve by kind, whatever the id says. + assert_eq!( + resolve_harness_in(AgentKind::Claude, None, &all) + .unwrap() + .program, + "claude" + ); + // Custom without an id, or with an unknown one, refuses. + let err = resolve_harness_in(AgentKind::Custom, None, &all).unwrap_err(); + assert!(err.to_string().contains("registry id"), "{err}"); + let err = resolve_harness_in(AgentKind::Custom, Some("gone"), &all).unwrap_err(); + assert!(err.to_string().contains("no longer defined"), "{err}"); + // A broken entry refuses with its reason, never launches. + let broken = CustomHarness { + program: String::new(), + ..agy.clone() + }; + let all = harness_registry_in(&std::collections::BTreeMap::new(), &[broken]); + let err = resolve_harness_in(AgentKind::Custom, Some("agy"), &all).unwrap_err(); + assert!(err.to_string().contains("no program"), "{err}"); + // The usable entry resolves, disabled or not: a harness switched + // off after its sessions were created keeps running them. + let off = CustomHarness { + enabled: false, + ..agy.clone() + }; + let all = harness_registry_in(&std::collections::BTreeMap::new(), &[off]); + assert_eq!( + resolve_harness_in(AgentKind::Custom, Some("agy"), &all) + .unwrap() + .program, + "agy" + ); + } + #[test] fn spawn_command_initial_prompt_is_the_trailing_positional_argument() { + let all = test_registry(); + let claude = test_harness(&all, AgentKind::Claude); + let codex = test_harness(&all, AgentKind::Codex); + let cursor = test_harness(&all, AgentKind::Cursor); + let pi = test_harness(&all, AgentKind::Pi); // The relocation notice trails everything, guidance included. let (_, args, resumed) = agent_spawn_command_with( - AgentKind::Claude, + &claude, Some("sid"), Some(Path::new(TEST_CWD)), Some("opus"), @@ -3436,13 +3805,13 @@ mod tests { true, ); assert!(resumed); - let mut expected = guided(&["--resume", "sid", "--model", "opus"]); + let mut expected = guided("--append-system-prompt", &["--resume", "sid", "--model", "opus"]); expected.push("carry on".into()); assert_eq!(args, expected); // Codex and cursor take it as their trailing positional too. assert_eq!( agent_spawn_command_with( - AgentKind::Codex, + &codex, Some("sid"), Some(Path::new(TEST_CWD)), None, @@ -3450,14 +3819,14 @@ mod tests { None, Some("carry on"), None, - true + true, ) .1, vec!["resume", "sid", "--cd", TEST_CWD, "--yolo", "carry on"] ); assert_eq!( agent_spawn_command_with( - AgentKind::Cursor, + &cursor, Some("sid"), Some(Path::new(TEST_CWD)), None, @@ -3465,18 +3834,18 @@ mod tests { None, Some("carry on"), None, - true + true, ) .1, vec!["--resume", "sid", "--force", "carry on"] ); // A fresh spawn with a starting prompt (an AGENT PRESET launch): // model, effort and system prompt all precede it. - let mut expected = guided(&["--model", "opus", "--effort", "high"]); + let mut expected = guided("--append-system-prompt", &["--model", "opus", "--effort", "high"]); expected.push("fix auth".into()); assert_eq!( agent_spawn_command_with( - AgentKind::Claude, + &claude, None, Some(Path::new(TEST_CWD)), Some("opus"), @@ -3484,14 +3853,14 @@ mod tests { None, Some("fix auth"), None, - true + true, ) .1, expected ); assert_eq!( agent_spawn_command_with( - AgentKind::Codex, + &codex, None, Some(Path::new(TEST_CWD)), Some("gpt-5.5"), @@ -3499,7 +3868,7 @@ mod tests { None, Some("fix auth"), None, - true + true, ) .1, vec![ @@ -3513,7 +3882,7 @@ mod tests { ); assert_eq!( agent_spawn_command_with( - AgentKind::Cursor, + &cursor, None, Some(Path::new(TEST_CWD)), None, @@ -3521,18 +3890,18 @@ mod tests { None, Some("fix auth"), None, - true + true, ) .1, vec!["--force", "fix auth"] ); // Pi: the prompt trails the resume id and the guidance, as pi's // `[messages...]` positional. - let mut expected = guided(&["--session-id", "sid"]); + let mut expected = guided("--append-system-prompt", &["--session-id", "sid"]); expected.push("carry on".into()); assert_eq!( agent_spawn_command_with( - AgentKind::Pi, + &pi, Some("sid"), Some(Path::new(TEST_CWD)), None, @@ -3540,7 +3909,7 @@ mod tests { None, Some("carry on"), None, - true + true, ) .1, expected @@ -3548,7 +3917,7 @@ mod tests { // An override is verbatim: no guidance, no prompt. assert_eq!( agent_spawn_command_with( - AgentKind::Claude, + &claude, None, Some(Path::new(TEST_CWD)), None, @@ -3556,7 +3925,7 @@ mod tests { Some("/bin/sh -i"), Some("carry on"), None, - true + true, ), ("/bin/sh".into(), vec!["-i".to_string()], false) ); @@ -3577,19 +3946,25 @@ mod tests { is_main: false, sort_order: 0, }; + let all = test_registry(); for kind in [AgentKind::Claude, AgentKind::Codex, AgentKind::Pi] { - let prompt = relocation_prompt(kind, &feat).unwrap_or_else(|| panic!("{kind:?}")); + let harness = test_harness(&all, kind); + assert!(harness.relocation_prompt, "{kind:?} maps the notice"); + let prompt = relocation_prompt(harness.relocation_prompt, &feat) + .unwrap_or_else(|| panic!("{kind:?}")); assert!(prompt.contains("`feat`"), "{kind:?}: {prompt}"); assert!(prompt.contains("/nebula-test/p-feat"), "{kind:?}: {prompt}"); assert!(prompt.contains("Continue the user's most recent request")); } - assert_eq!(relocation_prompt(AgentKind::Cursor, &feat), None); + let cursor = test_harness(&all, AgentKind::Cursor); + assert_eq!(relocation_prompt(cursor.relocation_prompt, &feat), None); // And the codex respawn it feeds: resumed, re-rooted in the // worktree, and opening on the notice. - let notice = relocation_prompt(AgentKind::Codex, &feat).unwrap(); + let codex = test_harness(&all, AgentKind::Codex); + let notice = relocation_prompt(codex.relocation_prompt, &feat).unwrap(); let (program, args, resumed) = agent_spawn_command_with( - AgentKind::Codex, + &codex, Some("sid"), Some(&feat.path), None, @@ -3623,8 +3998,10 @@ mod tests { branch: "fix", root: Some(Path::new("/w/nebula")), }); + let all = test_registry(); + let claude = test_harness(&all, AgentKind::Claude); let (_, args, resumed) = agent_spawn_command_with( - AgentKind::Claude, + &claude, Some("sid"), Some(Path::new(TEST_CWD)), None, @@ -3651,8 +4028,11 @@ mod tests { #[test] fn spawn_command_claude_cloud_passes_the_task_as_one_argument() { + let all = test_registry(); + let claude = test_harness(&all, AgentKind::Claude); assert_eq!( claude_cloud_spawn_command( + &claude, "Fix auth\nRun tests; don't stop", Some("opus"), Some("high"), @@ -3671,12 +4051,13 @@ mod tests { ) ); assert_eq!( - claude_cloud_spawn_command("--dangerously-skip-permissions", None, None, None).1, + claude_cloud_spawn_command(&claude, "--dangerously-skip-permissions", None, None, None) + .1, vec!["--cloud=--dangerously-skip-permissions"] ); // Overrides (tests) stay verbatim — no cloud flag at all. assert_eq!( - claude_cloud_spawn_command("task", None, None, Some("/bin/true")).1, + claude_cloud_spawn_command(&claude, "task", None, None, Some("/bin/true")).1, Vec::::new() ); } @@ -3751,6 +4132,38 @@ mod tests { const PANE_ENV: &str = "unset NO_COLOR FORCE_COLOR; export TERM=xterm-256color COLORTERM=truecolor;"; + #[test] + fn cli_probe_line_looks_the_program_up_verbatim() { + // Built-ins read exactly as before the registry. + assert_eq!( + cli_probe_line("claude"), + "command -v 'claude' >/dev/null 2>&1" + ); + assert_eq!( + cli_probe_line("cursor-agent"), + "command -v 'cursor-agent' >/dev/null 2>&1" + ); + // A config-named program is any string. A quote in it stays + // inside the word instead of closing it and opening a command. + let hostile = "x'; echo INJECTED; echo '"; + let line = cli_probe_line(hostile); + assert_eq!( + line, + "command -v 'x'\\''; echo INJECTED; echo '\\''' >/dev/null 2>&1" + ); + // And a real shell agrees: the lookup fails quietly, nothing runs. + let out = std::process::Command::new("/bin/sh") + .args(["-c", &line]) + .output() + .expect("/bin/sh"); + assert!(!out.status.success(), "no such program"); + assert!( + out.stdout.is_empty(), + "{:?}", + String::from_utf8_lossy(&out.stdout) + ); + } + #[test] fn login_shell_wrap_quotes_args_and_leaves_the_command_word_bare() { let (program, args) = login_shell_wrap( @@ -4030,6 +4443,7 @@ mod tests { worktree: worktree.clone(), name: "cloud".into(), kind: AgentKind::Claude, + custom_harness: None, model: None, effort: None, auto_title: false, @@ -4047,6 +4461,7 @@ mod tests { worktree: worktree.clone(), name: "cloud".into(), kind: AgentKind::Claude, + custom_harness: None, model: None, effort: None, auto_title: false, @@ -4064,6 +4479,7 @@ mod tests { worktree: worktree.clone(), name: "cloud".into(), kind: AgentKind::Claude, + custom_harness: None, model: None, effort: None, auto_title: false, @@ -4081,6 +4497,7 @@ mod tests { worktree, name: "cloud".into(), kind: AgentKind::Codex, + custom_harness: None, model: None, effort: None, auto_title: false, @@ -4101,6 +4518,7 @@ mod tests { worktree: WorktreeId("unused".into()), name: "pr".into(), kind, + custom_harness: None, model: None, effort: None, auto_title: false, @@ -4110,6 +4528,12 @@ mod tests { issue_url: None, }; for kind in AgentKind::ALL { + if kind == AgentKind::Custom { + // Without a registry id the custom refusal comes first. + let err = daemon.create_agent(spec(kind, None)).await.unwrap_err(); + assert!(err.to_string().contains("registry id"), "{err}"); + continue; + } // Validation passes for every harness; the missing worktree is // what stops this spec, one check later. let err = daemon.create_agent(spec(kind, None)).await.unwrap_err(); @@ -4197,6 +4621,7 @@ mod tests { project: project.clone(), name: "pr".into(), kind: AgentKind::Claude, + custom_harness: None, model: None, effort: None, auto_title: false, @@ -4218,6 +4643,7 @@ mod tests { worktree: WorktreeId("unused".into()), name: "issue".into(), kind, + custom_harness: None, model: None, effort: None, auto_title: true, @@ -4227,6 +4653,11 @@ mod tests { issue_url: Some("https://github.com/o/r/issues/15".into()), }; for kind in AgentKind::ALL { + if kind == AgentKind::Custom { + let err = daemon.create_agent(spec(kind, None)).await.unwrap_err(); + assert!(err.to_string().contains("registry id"), "{err}"); + continue; + } let err = daemon.create_agent(spec(kind, None)).await.unwrap_err(); assert!( err.to_string().contains("worktree not found"), @@ -4256,6 +4687,7 @@ mod tests { worktree: WorktreeId("unused".into()), name: "preset".into(), kind, + custom_harness: None, model: None, effort: None, auto_title: true, @@ -4372,6 +4804,7 @@ mod tests { archived_at: 0, unseen: false, kind: AgentKind::Claude, + custom_harness: None, model: None, effort: None, session_id: session_id.map(str::to_string), @@ -4556,6 +4989,7 @@ mod tests { archived_at: 0, unseen: false, kind: AgentKind::Claude, + custom_harness: None, model: None, effort: None, session_id: None, @@ -5313,14 +5747,21 @@ mod tests { fn cli_missing_message_names_the_binary_not_the_kind() { // Cursor ships its agent as `cursor-agent`; naming the kind would // send the user off to install the wrong thing. - assert!(cli_missing_message(AgentKind::Cursor).starts_with("cursor-agent was not found")); - assert!(cli_missing_message(AgentKind::Claude).starts_with("claude was not found")); - assert!(cli_missing_message(AgentKind::Codex).starts_with("codex was not found")); - assert!(cli_missing_message(AgentKind::Pi).starts_with("pi was not found")); + assert!( + cli_missing_message(AgentKind::Cursor.cli_program()) + .starts_with("cursor-agent was not found") + ); + assert!(cli_missing_message(AgentKind::Claude.cli_program()).starts_with("claude was not found")); + assert!(cli_missing_message(AgentKind::Codex.cli_program()).starts_with("codex was not found")); + assert!(cli_missing_message(AgentKind::Pi.cli_program()).starts_with("pi was not found")); + assert!(cli_missing_message("agy").starts_with("agy was not found")); // No "restart nebula": agent CLIs are spawned through the user's // login shell, so a fresh install is picked up on the next try. for kind in AgentKind::ALL { - let msg = cli_missing_message(kind); + if kind == AgentKind::Custom { + continue; // no static program; resolved through the registry + } + let msg = cli_missing_message(kind.cli_program()); assert!(msg.contains("try again"), "{msg}"); assert!(!msg.contains("restart"), "{msg}"); } diff --git a/crates/nebula-daemon/src/server.rs b/crates/nebula-daemon/src/server.rs index e828d19..753bddb 100644 --- a/crates/nebula-daemon/src/server.rs +++ b/crates/nebula-daemon/src/server.rs @@ -333,6 +333,7 @@ async fn handle_client(daemon: Arc, stream: UnixStream) -> Result<()> { worktree, name, kind, + custom_harness, model, effort, auto_title, @@ -353,6 +354,7 @@ async fn handle_client(daemon: Arc, stream: UnixStream) -> Result<()> { worktree: worktree.clone(), name, kind, + custom_harness, model, effort, auto_title, @@ -390,6 +392,7 @@ async fn handle_client(daemon: Arc, stream: UnixStream) -> Result<()> { project, name, kind, + custom_harness, model, effort, auto_title, @@ -401,6 +404,7 @@ async fn handle_client(daemon: Arc, stream: UnixStream) -> Result<()> { project: project.clone(), name, kind, + custom_harness, model, effort, auto_title, diff --git a/crates/nebula-daemon/src/session_title.rs b/crates/nebula-daemon/src/session_title.rs index 4b3a1d6..432a952 100644 --- a/crates/nebula-daemon/src/session_title.rs +++ b/crates/nebula-daemon/src/session_title.rs @@ -140,16 +140,41 @@ pub struct TitleState { pub claude_title: Option, pub auto_title_pending: bool, pub kind: AgentKind, + /// Registry id when `kind` is [`AgentKind::Custom`]; lets the hook + /// reply treat rows on the Claude dialect like Claude itself. + pub custom_harness: Option, } impl TitleState { + /// Whether this row's harness speaks the Claude hook dialect: builtin + /// Claude, or a custom entry naming it. Reads the current registry for + /// custom rows; unknown or broken ids read as no dialect. Built-in + /// Claude never consults it (its replies only exist while its hooks + /// are installed). + pub fn claude_like(&self) -> bool { + match self.kind { + AgentKind::Claude => true, + AgentKind::Custom => { + let config = crate::config::Config::load(); + let all = nebula_core::harness::registry( + &config.harnesses, + &config.custom_harnesses, + ); + nebula_core::harness::resolve(&all, self.kind, self.custom_harness.as_deref()) + .map(|descriptor| descriptor.claude_like()) + .unwrap_or(false) + } + _ => false, + } + } + /// The title the `UserPromptSubmit` reply should hand Claude: the /// row's name when it is user- or AUTO-TITLE-set and not what Claude /// already holds. `None` while the AUTO-TITLE is still pending (the /// instruction rides that reply instead), for a default name, when the - /// two agree, and for every non-Claude harness. + /// two agree, and for every harness outside the Claude dialect. pub fn to_push(&self) -> Option<&str> { - if self.kind != AgentKind::Claude + if !self.claude_like() || self.auto_title_pending || is_default_agent_name(&self.name) || self.claude_title.as_deref() == Some(self.name.as_str()) @@ -360,6 +385,7 @@ mod tests { archived_at: 0, unseen: false, kind: AgentKind::Claude, + custom_harness: None, model: None, effort: None, session_id: None, @@ -452,6 +478,7 @@ mod tests { claude_title: claude.map(str::to_string), auto_title_pending: pending, kind, + custom_harness: None, }; let claude = AgentKind::Claude; assert_eq!( diff --git a/crates/nebula-daemon/src/sibling.rs b/crates/nebula-daemon/src/sibling.rs index 4f221ae..3215a03 100644 --- a/crates/nebula-daemon/src/sibling.rs +++ b/crates/nebula-daemon/src/sibling.rs @@ -66,10 +66,19 @@ impl Daemon { } else { (None, None) }; + // A sibling on the same harness keeps its custom registry id; an + // override to another harness drops it (an override to Custom + // without an id is refused at create with its reason). + let custom_harness = if kind == caller.kind { + caller.custom_harness.clone() + } else { + None + }; Ok(CreateAgentSpec { worktree: caller.worktree_id.clone(), name: sibling_name(&taken), kind, + custom_harness, model, effort, auto_title: true, @@ -146,6 +155,7 @@ mod tests { archived_at: 0, unseen: false, kind, + custom_harness: None, model: model.map(str::to_string), effort: model.map(|_| "high".to_string()), session_id: Some("s1".into()), diff --git a/crates/nebula-daemon/src/store.rs b/crates/nebula-daemon/src/store.rs index 3cd1589..53e950c 100644 --- a/crates/nebula-daemon/src/store.rs +++ b/crates/nebula-daemon/src/store.rs @@ -263,6 +263,11 @@ const MIGRATIONS: &[&str] = &[ " ALTER TABLE terminals ADD COLUMN run_command TEXT; ", + // 27: the custom harness registry id for `AgentKind::Custom` rows. + // Nullable: every built-in harness reads its kind column alone. + " + ALTER TABLE agents ADD COLUMN custom_harness TEXT; + ", ]; pub struct Store { @@ -551,8 +556,8 @@ impl Store { issue_url: Option<&str>, ) -> Result<()> { self.conn.lock().unwrap().execute( - "INSERT INTO agents (id, worktree_id, name, status, archived, archived_at, kind, claude_session_id, sort_order, created_at, status_changed_at, model, effort, auto_title_pending, unseen, cloud_session_id, pr_url, issue_url) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)", + "INSERT INTO agents (id, worktree_id, name, status, archived, archived_at, kind, claude_session_id, sort_order, created_at, status_changed_at, model, effort, auto_title_pending, unseen, cloud_session_id, pr_url, issue_url, custom_harness) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)", params![ a.id.as_str(), a.worktree_id.as_str(), @@ -572,6 +577,7 @@ impl Store { a.cloud_session_id, pr_url, issue_url, + a.custom_harness, ], )?; Ok(()) @@ -697,7 +703,7 @@ impl Store { pub fn agent_title_state(&self, id: &AgentId) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( - "SELECT name, claude_title, auto_title_pending, kind FROM agents WHERE id = ?1", + "SELECT name, claude_title, auto_title_pending, kind, custom_harness FROM agents WHERE id = ?1", )?; let mut rows = stmt.query(params![id.as_str()])?; match rows.next()? { @@ -705,7 +711,8 @@ impl Store { name: row.get(0)?, claude_title: row.get(1)?, auto_title_pending: row.get::<_, i64>(2)? != 0, - kind: AgentKind::parse(&row.get::<_, String>(3)?).unwrap_or_default(), + kind: parse_agent_kind(&row.get::<_, String>(3)?), + custom_harness: row.get(4)?, })), None => Ok(None), } @@ -1066,7 +1073,7 @@ const PROJECT_COLUMNS: &str = "id, name, repo_path, sort_order, workspace_id"; const WORKTREE_COLUMNS: &str = "id, project_id, path, branch, is_main, sort_order"; const AGENT_COLUMNS: &str = "id, worktree_id, name, status, archived, kind, \ claude_session_id, sort_order, status_changed_at, model, effort, \ - archived_at, unseen, cloud_session_id, recent_prompts"; + archived_at, unseen, cloud_session_id, recent_prompts, custom_harness"; const TERMINAL_COLUMNS: &str = "id, worktree_id, name, sort_order, run_command"; const LINK_COLUMNS: &str = "id, worktree_id, url, sort_order"; @@ -1110,7 +1117,7 @@ fn row_to_agent(r: &rusqlite::Row) -> rusqlite::Result { name: r.get(2)?, status: AgentStatus::parse(&r.get::<_, String>(3)?).unwrap_or(AgentStatus::Fresh), archived: r.get::<_, i64>(4)? != 0, - kind: AgentKind::parse(&r.get::<_, String>(5)?).unwrap_or_default(), + kind: parse_agent_kind(&r.get::<_, String>(5)?), session_id: r.get(6)?, sort_order: r.get(7)?, status_changed_at: r.get(8)?, @@ -1121,9 +1128,22 @@ fn row_to_agent(r: &rusqlite::Row) -> rusqlite::Result { cloud_session_id: r.get(13)?, alive: false, recent_prompts: parse_prompts(r.get::<_, Option>(14)?.as_deref()), + custom_harness: r.get(15)?, }) } +/// A stored kind string back into its kind. Bare `"custom"` never parses +/// through [`AgentKind::parse`] (a custom harness is meaningless without +/// its registry id), so the row mappers name it here instead; anything +/// else unknown reads as the default rather than failing the row load. +fn parse_agent_kind(raw: &str) -> AgentKind { + if raw.trim() == AgentKind::Custom.as_str() { + AgentKind::Custom + } else { + AgentKind::parse(raw).unwrap_or_default() + } +} + /// The `recent_prompts` column: NULL is the empty history, and a column /// that will not parse (a hand edit, a downgrade) reads as empty too /// rather than failing every row load. @@ -1186,6 +1206,7 @@ mod tests { archived_at: 0, unseen: false, kind: AgentKind::Claude, + custom_harness: None, model: Some("opus".into()), effort: Some("high".into()), session_id: Some("sess-123".into()), @@ -1208,6 +1229,7 @@ mod tests { archived_at: 0, unseen: false, kind: AgentKind::Codex, + custom_harness: None, model: None, effort: None, session_id: None, @@ -1227,6 +1249,7 @@ mod tests { archived_at: 0, unseen: false, kind: AgentKind::Cursor, + custom_harness: None, model: None, effort: None, session_id: None, @@ -1247,6 +1270,7 @@ mod tests { archived_at: 0, unseen: false, kind: AgentKind::Claude, + custom_harness: None, model: None, effort: None, session_id: None, @@ -1269,11 +1293,40 @@ mod tests { assert_eq!(agents[0].session_id.as_deref(), Some("sess-123")); assert_eq!(agents[0].model.as_deref(), Some("opus")); assert_eq!(agents[0].effort.as_deref(), Some("high")); + assert_eq!(agents[0].custom_harness, None, "built-ins store no id"); assert_eq!( store.agent_pr_url(&agents[0].id).unwrap().as_deref(), Some(pr_url) ); assert_eq!(store.agent_pr_url(&agents[1].id).unwrap(), None); + + // A Custom row round-trips its registry id beside the kind, so + // respawns find the same entry (migration 27). + let custom = Agent { + id: AgentId::generate(), + worktree_id: worktree.id.clone(), + name: "agy-1".into(), + status: AgentStatus::Fresh, + archived: false, + archived_at: 0, + unseen: false, + kind: AgentKind::Custom, + custom_harness: Some("agy".into()), + model: Some("big-1".into()), + effort: None, + session_id: None, + cloud_session_id: None, + sort_order: 0, + status_changed_at: 0, + alive: false, + recent_prompts: Vec::new(), + }; + store.insert_agent(&custom).unwrap(); + let (_, _, reloaded, _) = store.load_tree().unwrap(); + let back = reloaded.iter().find(|a| a.id == custom.id).unwrap(); + assert_eq!(back.kind, AgentKind::Custom); + assert_eq!(back.custom_harness.as_deref(), Some("agy")); + assert_eq!(back.model.as_deref(), Some("big-1")); assert_eq!(agents[1].kind, AgentKind::Codex); assert_eq!(agents[1].model, None); assert_eq!(agents[2].kind, AgentKind::Cursor); @@ -1729,6 +1782,7 @@ mod tests { archived_at: 0, unseen: false, kind: AgentKind::Claude, + custom_harness: None, model: None, effort: None, session_id: None, @@ -1816,6 +1870,7 @@ mod tests { archived_at: 0, unseen: false, kind: AgentKind::Claude, + custom_harness: None, model: None, effort: None, session_id: None, @@ -1945,6 +2000,7 @@ mod tests { archived_at: 0, unseen: false, kind: AgentKind::Claude, + custom_harness: None, model: None, effort: None, session_id: None, @@ -2010,6 +2066,7 @@ mod tests { archived_at: 0, unseen: false, kind: AgentKind::Claude, + custom_harness: None, model: None, effort: None, session_id: None, @@ -2109,6 +2166,7 @@ mod tests { archived_at: 0, unseen: false, kind: AgentKind::Claude, + custom_harness: None, model: None, effort: None, session_id: None, diff --git a/crates/nebula-tui/src/agent_picker.rs b/crates/nebula-tui/src/agent_picker.rs index cc726d5..238b456 100644 --- a/crates/nebula-tui/src/agent_picker.rs +++ b/crates/nebula-tui/src/agent_picker.rs @@ -11,7 +11,7 @@ use crate::app::{App, ContextMenu, MenuAction, MenuItem, Overlay}; use crate::config::Config; use crate::pull_request::{OpenPr, PrLaunch}; use crate::quick_prompt::QuickReturn; -use nebula_core::{AgentKind, WorktreeId}; +use nebula_core::{Agent, AgentKind, WorktreeId}; /// Shown instead of a picker when a hand-edited config has switched every /// harness off (the AGENTS TAB refuses to turn off the last one). @@ -29,7 +29,7 @@ pub(crate) struct KindPicker { /// The QUICK PROMPT box owed back (its `Tab` picker). pub quick: Option>, /// The row to start on; the first row when None or not offered. - pub hover: Option, + pub hover: Option, } impl KindPicker { @@ -68,42 +68,59 @@ impl KindPicker { title: back.launch.picker_title(), worktree, pr: None, - hover: Some(back.launch.kind), + hover: Some(HarnessRow { + kind: back.launch.kind, + custom: back.launch.custom.clone(), + }), quick: Some(Box::new(back)), } } } -/// The harnesses still enabled on the AGENTS TAB, or None — with the FLASH -/// set — when a hand-edited config disabled them all. An empty -/// `ContextMenu` panics on Enter and `j`, so no caller opens one. -pub(crate) fn enabled_kinds_or_flash(app: &mut App) -> Option> { - let kinds = Config::load().enabled_kinds(); - if kinds.is_empty() { +/// One launch row: a built-in kind, or a custom registry entry. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct HarnessRow { + pub kind: AgentKind, + /// Registry id when `kind` is [`AgentKind::Custom`]. + pub custom: Option, +} + +/// The harnesses the pickers offer — enabled built-ins plus usable custom +/// entries — or None, with the FLASH set, when a hand-edited config left +/// nothing to offer. An empty `ContextMenu` panics on Enter and `j`, so no +/// caller opens one. +pub(crate) fn enabled_harnesses_or_flash(app: &mut App) -> Option> { + let rows: Vec = Config::load() + .offered_harnesses() + .into_iter() + .map(|(kind, custom)| HarnessRow { kind, custom }) + .collect(); + if rows.is_empty() { app.flash = Some(NO_HARNESS_FLASH.into()); return None; } - Some(kinds) + Some(rows) } /// One `NewAgentOfKind` row per harness, labelled by `label`, every row /// carrying the same launch context (`→` on any of them drills into that -/// kind's MODEL / EFFORT submenus with the context intact). +/// kind's MODEL submenu with the context intact; customs offer no EFFORT +/// submenu — see `effort_choices`). pub(crate) fn kind_rows( - kinds: &[AgentKind], + rows: &[HarnessRow], worktree: &WorktreeId, pr: Option<&PrLaunch>, quick: Option<&QuickReturn>, - label: impl Fn(AgentKind) -> String, + label: impl Fn(AgentKind, Option<&str>) -> String, ) -> Vec { - kinds - .iter() - .map(|&kind| { + rows.iter() + .map(|row| { MenuItem::new( - label(kind), + label(row.kind, row.custom.as_deref()), MenuAction::NewAgentOfKind { worktree: worktree.clone(), - kind, + kind: row.kind, + custom: row.custom.clone(), model: None, effort: None, cloud: false, @@ -115,9 +132,24 @@ pub(crate) fn kind_rows( .collect() } +/// The label a picker row shows: the registry entry's label for a known +/// id (falling back to the id when it carries none), the kind name +/// otherwise — including a custom id the registry no longer names. +pub(crate) fn harness_label(kind: AgentKind, custom: Option<&str>) -> String { + let cfg = Config::load(); + match (kind, custom) { + (AgentKind::Custom, Some(id)) + if cfg.harness_registry().iter().any(|entry| entry.id == id) => + { + cfg.effective_harness_by_id(id).display_label().to_string() + } + _ => kind_label(kind).to_string(), + } +} + /// Open `picker` as the OVERLAY, or FLASH when no harness is enabled. pub(crate) fn open_kind_picker(app: &mut App, picker: KindPicker) { - let Some(kinds) = enabled_kinds_or_flash(app) else { + let Some(rows) = enabled_harnesses_or_flash(app) else { return; }; let KindPicker { @@ -128,11 +160,13 @@ pub(crate) fn open_kind_picker(app: &mut App, picker: KindPicker) { hover, } = picker; let hover = hover - .and_then(|wanted| kinds.iter().position(|kind| *kind == wanted)) + .and_then(|wanted| { + rows.iter().position(|row| { + row.kind == wanted.kind && row.custom.as_deref() == wanted.custom.as_deref() + }) + }) .unwrap_or(0); - let items = kind_rows(&kinds, &worktree, pr.as_ref(), quick.as_deref(), |kind| { - kind_label(kind).to_string() - }); + let items = kind_rows(&rows, &worktree, pr.as_ref(), quick.as_deref(), harness_label); app.overlay = Some(Overlay::Menu(ContextMenu { title: Some(title), items, @@ -149,18 +183,39 @@ pub(crate) fn open_kind_picker(app: &mut App, picker: KindPicker) { /// harness, and none (no FLASH: the menu's other verbs still apply) when /// every harness is off. pub(crate) fn pr_session_menu_rows(worktree: WorktreeId, pr: &OpenPr) -> Vec { - let kinds = Config::load().enabled_kinds(); - kind_rows(&kinds, &worktree, Some(&PrLaunch::of(pr)), None, |kind| { - format!("New {} session", kind_label(kind)) + let rows: Vec = Config::load() + .offered_harnesses() + .into_iter() + .map(|(kind, custom)| HarnessRow { kind, custom }) + .collect(); + kind_rows(&rows, &worktree, Some(&PrLaunch::of(pr)), None, |kind, custom| { + format!("New {} session", harness_label(kind, custom)) }) } +/// The harness badge a session row wears: the built-in name, or the +/// custom entry's label (the id when the entry is gone, the kind name +/// when the row somehow names none). +pub(crate) fn session_harness_badge(agent: &Agent) -> String { + match (agent.kind, agent.custom_harness.as_deref()) { + (AgentKind::Custom, Some(id)) => Config::load() + .effective_harness_by_id(id) + .display_label() + .to_string(), + _ => agent.kind.as_str().to_string(), + } +} + pub(crate) fn kind_label(kind: AgentKind) -> &'static str { match kind { AgentKind::Claude => "Claude", AgentKind::Codex => "Codex", AgentKind::Cursor => "Cursor", AgentKind::Pi => "Pi", + AgentKind::Muse => "Muse", + // Custom rows label through `harness_label` (the entry's own + // label); this is only the fallback when its entry is gone. + AgentKind::Custom => "Custom", } } @@ -196,36 +251,43 @@ mod tests { menu.items.iter().map(|item| item.label.as_str()).collect() } - /// Every row is the same launch context under a different kind, so a - /// submenu drilled from any row keeps that context. + /// Every row is the same launch context under a different harness, so + /// a submenu drilled from any row keeps that context — including the + /// custom registry id. #[test] fn kind_rows_carry_the_launch_context_into_every_row() { let worktree = WorktreeId("w1".into()); let pr = PrLaunch::of(&open_pr()); - let rows = kind_rows(&AgentKind::ALL, &worktree, Some(&pr), None, |kind| { - format!("New {} session", kind_label(kind)) - }); - let names: Vec<&str> = rows.iter().map(|row| row.label.as_str()).collect(); - let expected: Vec = AgentKind::ALL - .iter() - .map(|kind| format!("New {} session", kind_label(*kind))) + let harness_rows: Vec = AgentKind::ALL + .into_iter() + .filter(|kind| *kind != AgentKind::Custom) + .map(|kind| HarnessRow { kind, custom: None }) + .chain([HarnessRow { + kind: AgentKind::Custom, + custom: Some("agy".into()), + }]) .collect(); - assert_eq!(names, expected); - assert!(names.contains(&"New Codex session")); - for (row, expected) in rows.iter().zip(AgentKind::ALL) { + let rows = kind_rows(&harness_rows, &worktree, Some(&pr), None, |kind, custom| { + format!("New {} session", harness_label(kind, custom)) + }); + assert!(rows.iter().any(|row| row.label == "New Codex session")); + assert!(rows.iter().any(|row| row.label == "New Custom session")); + for (row, expected) in rows.iter().zip(harness_rows.iter()) { assert!( matches!( &row.action, MenuAction::NewAgentOfKind { worktree, kind, + custom, model: None, effort: None, cloud: false, pr: Some(pr), quick: None, } if worktree.as_str() == "w1" - && *kind == expected + && *kind == expected.kind + && custom.as_deref() == expected.custom.as_deref() && pr.url == PR_URL && pr.head == PR_HEAD ), @@ -235,6 +297,81 @@ mod tests { } } + /// Custom entries ride after the built-ins under their own labels; + /// disabled, invalid and (under the hide switch) missing ones stay + /// out, and their rows carry the registry id into the launch. + #[test] + fn picker_lists_usable_custom_entries_after_the_builtins() { + let json = r#"{"custom_harnesses": [ + {"id": "agy", "label": "Agy", "program": "agy"}, + {"id": "off", "label": "Off", "program": "off", "enabled": false}, + {"id": "broken", "label": "Broken", "program": ""} + ]}"#; + pinned(json, || { + let worktree = WorktreeId("w1".into()); + let mut app = App::new(); + open_kind_picker(&mut app, KindPicker::new_session(worktree)); + let Some(Overlay::Menu(menu)) = &app.overlay else { + panic!("{:?}", app.overlay); + }; + let names = labels(menu); + assert_eq!(names.last(), Some(&"Agy")); + assert!(!names.contains(&"Off"), "{names:?}"); + assert!(!names.contains(&"Broken"), "{names:?}"); + let agy = menu.items.iter().find(|item| item.label == "Agy").unwrap(); + assert!(matches!( + &agy.action, + MenuAction::NewAgentOfKind { + kind: AgentKind::Custom, + custom: Some(id), + .. + } if id == "agy" + )); + assert_eq!(agy.action.submenu(), Some(SubmenuKind::Models)); + }); + } + + /// The Agents tab's per-entry Enabled row is the toggle now: flip + /// it, save, and the picker drops the entry until it flips back. + #[test] + fn agents_tab_toggles_custom_entries_off_the_picker() { + use crate::config::{HarnessField, locate_agent}; + let json = r#"{"custom_harnesses": [ + {"id": "agy", "label": "Agy", "program": "agy"} + ]}"#; + pinned(json, || { + let worktree = WorktreeId("w1".into()); + let mut app = App::new(); + open_kind_picker(&mut app, KindPicker::new_session(worktree.clone())); + let Some(Overlay::Menu(menu)) = &app.overlay else { + panic!("{:?}", app.overlay); + }; + assert!(labels(menu).contains(&"Agy")); + + let mut cfg = Config::load(); + let (tab, row) = locate_agent("agy", HarnessField::Enabled).unwrap(); + cfg.cycle(tab, row, 0); + cfg.save().unwrap(); + + open_kind_picker(&mut app, KindPicker::new_session(worktree)); + let Some(Overlay::Menu(menu)) = &app.overlay else { + panic!("{:?}", app.overlay); + }; + assert!(!labels(menu).contains(&"Agy"), "{:?}", labels(menu)); + + let mut cfg = Config::load(); + let (tab, row) = locate_agent("agy", HarnessField::Enabled).unwrap(); + cfg.cycle(tab, row, 0); + cfg.save().unwrap(); + assert!( + Config::load() + .offered_harnesses() + .contains(&(AgentKind::Custom, Some("agy".into()))), + "flipping back re-offers the entry" + ); + }); + } + /// The three surfaces differ only in title, context and starting row; /// a harness switched off on the AGENTS TAB is missing from all of them. #[test] @@ -242,10 +379,12 @@ mod tests { pinned(r#"{"codex_enabled": false}"#, || { let worktree = WorktreeId("w1".into()); let mut app = App::new(); - // Every harness but the one switched off, in the ALL order. + // Every built-in harness but the one switched off, in the ALL + // order. A bare Custom kind is never offered (entries come + // from the registry), and the pinned config defines none. let offered: Vec<&str> = AgentKind::ALL .iter() - .filter(|kind| **kind != AgentKind::Codex) + .filter(|kind| **kind != AgentKind::Codex && **kind != AgentKind::Custom) .map(|kind| kind_label(*kind)) .collect(); assert!(offered.starts_with(&["Claude", "Cursor"]), "{offered:?}"); @@ -277,6 +416,7 @@ mod tests { launch: QuickLaunch { target: crate::quick_prompt::QuickTarget::Worktree(worktree.clone()), kind: AgentKind::Cursor, + custom: None, model: None, effort: None, preset: None, @@ -313,7 +453,7 @@ mod tests { #[test] fn no_harness_flashes_the_picker_and_empties_the_menu_rows() { pinned( - r#"{"claude_enabled": false, "codex_enabled": false, "cursor_enabled": false, "pi_enabled": false}"#, + r#"{"claude_enabled": false, "codex_enabled": false, "cursor_enabled": false, "pi_enabled": false, "muse_enabled": false}"#, || { let worktree = WorktreeId("w1".into()); let mut app = App::new(); diff --git a/crates/nebula-tui/src/agent_presets.rs b/crates/nebula-tui/src/agent_presets.rs index 0e55383..ce0e3e5 100644 --- a/crates/nebula-tui/src/agent_presets.rs +++ b/crates/nebula-tui/src/agent_presets.rs @@ -24,6 +24,9 @@ pub struct AgentPreset { /// The CLI the preset launches. #[serde(default)] pub kind: AgentKind, + /// Registry id when `kind` is [`AgentKind::Custom`]. + #[serde(default)] + pub custom_harness: Option, /// Launch model; None = follow the Settings → Agents default. #[serde(default)] pub model: Option, @@ -47,7 +50,11 @@ impl AgentPreset { /// `claude · opus · high` / `codex · gpt-5.5` / `cursor` — the kind plus /// whichever of model and effort the preset pins. pub fn spec_label(&self) -> String { - let mut parts = vec![self.kind.as_str().to_string()]; + let harness = self + .custom_harness + .as_deref() + .unwrap_or_else(|| self.kind.as_str()); + let mut parts = vec![harness.to_string()]; parts.extend(self.model.iter().cloned()); parts.extend(self.effort.iter().cloned()); parts.join(" · ") @@ -169,6 +176,7 @@ mod tests { AgentPreset { name: name.into(), kind, + custom_harness: None, model: None, effort: None, prefix: String::new(), diff --git a/crates/nebula-tui/src/app.rs b/crates/nebula-tui/src/app.rs index 4b3c684..60bff11 100644 --- a/crates/nebula-tui/src/app.rs +++ b/crates/nebula-tui/src/app.rs @@ -166,6 +166,10 @@ pub enum MenuAction { NewAgentOfKind { worktree: WorktreeId, kind: AgentKind, + /// Registry id when `kind` is [`AgentKind::Custom`]; None for + /// built-ins. Carried through the MODEL submenu, the QUICK PROMPT + /// box and the launch draft into `CreateAgent::custom_harness`. + custom: Option, model: Option, effort: Option, /// One-shot launch modifier for Claude. The task itself is collected @@ -244,17 +248,19 @@ impl MenuAction { match self { MenuAction::NewAgentOfKind { kind, + custom, model, effort, .. } => { - if crate::config::model_choices(*kind).is_empty() { + if crate::config::model_choices(*kind, custom.as_deref()).is_empty() { return None; } match (model, effort) { (None, None) => Some(SubmenuKind::Models), (Some(m), None) - if !crate::config::effort_choices(*kind, Some(m)).is_empty() => + if !crate::config::effort_choices(*kind, Some(m), custom.as_deref()) + .is_empty() => { Some(SubmenuKind::Efforts) } @@ -400,6 +406,7 @@ impl ContextMenu { match &self.items.get(self.hover)?.action { MenuAction::NewAgentOfKind { kind: AgentKind::Claude, + custom: None, cloud, .. } => Some(*cloud), @@ -407,6 +414,17 @@ impl ContextMenu { } } + /// The harnessed launch under the cursor, if the hovered row starts + /// one: the New session picker, its PR sibling, the quick prompt + /// picker, and their model/effort submenus all carry it. Gates the + /// `?` jump to agent settings. + pub fn hovered_agent_kind(&self) -> Option<(AgentKind, Option)> { + match &self.items.get(self.hover)?.action { + MenuAction::NewAgentOfKind { kind, custom, .. } => Some((*kind, custom.clone())), + _ => None, + } + } + /// Toggle the highlighted Claude row and keep the state visible in the /// label. False means Tab did not belong to this menu/row. pub fn toggle_hovered_claude_cloud(&mut self) -> bool { @@ -505,6 +523,8 @@ pub enum PromptKind { NewPrAgent { worktree: WorktreeId, kind: AgentKind, + /// Registry id when `kind` is [`AgentKind::Custom`]. + custom: Option, /// Resolved launch options (picker choice or configured default); /// None = the CLI's own default. model: Option, @@ -3806,6 +3826,7 @@ mod tests { unseen: false, status_changed_at: 0, kind: AgentKind::Claude, + custom_harness: None, model: None, effort: None, session_id: None, diff --git a/crates/nebula-tui/src/bundle.rs b/crates/nebula-tui/src/bundle.rs index 5163603..a123b36 100644 --- a/crates/nebula-tui/src/bundle.rs +++ b/crates/nebula-tui/src/bundle.rs @@ -43,6 +43,11 @@ const CONFIG: &str = "config"; const PRESETS: &str = "agent_presets"; const HOSTS: &str = "ssh_hosts"; +/// `config.json` keys that name programs the daemon executes. They never +/// leave the machine over `nebula ssh`: a remote keeps its own harness +/// table, so a forwarded config can never repoint what the remote runs. +const EXEC_KEYS: [&str; 2] = ["harnesses", "custom_harnesses"]; + /// Each section, with the name its file has in a data dir. const SECTIONS: [(&str, &str); 3] = [ (CONFIG, "config.json"), @@ -114,6 +119,22 @@ pub fn export(paths: &Paths, scope: Scope) -> (Value, Vec) { settings::read_array(&paths.hosts).map(|list| list.map(Value::Array)), ); } + if scope == Scope::Remote { + if let Some(Value::Object(config)) = bundle.get_mut(CONFIG) { + let mut stripped = Vec::new(); + for key in EXEC_KEYS { + if config.remove(key).is_some() { + stripped.push(key); + } + } + if !stripped.is_empty() { + warnings.push(format!( + "left {} out: the remote keeps its own harness table", + stripped.join(", ") + )); + } + } + } (Value::Object(bundle), warnings) } @@ -510,6 +531,7 @@ pub enum ConfigOp { Path, Export { path: Option }, Import { source: String }, + Harnesses, } pub fn run(op: ConfigOp) -> Result<()> { @@ -526,6 +548,19 @@ pub fn run(op: ConfigOp) -> Result<()> { } Ok(()) } + ConfigOp::Harnesses => { + // The registry as launches read it: the compiled-in rows with + // the `harnesses` map, the legacy list and the legacy keys + // folded in. Copy a row into config.json `harnesses` to + // override it field by field (`null` clears a nullable row). + let registry = crate::config::Config::load().harness_registry(); + let mut text = serde_json::to_string_pretty(®istry)?; + text.push('\n'); + std::io::stdout() + .lock() + .write_all(text.as_bytes()) + .context("writing to stdout") + } ConfigOp::Export { path } => { let (bundle, warnings) = export(&paths, Scope::Backup); for warning in &warnings { @@ -630,6 +665,32 @@ mod tests { assert_eq!(section_count(&remote), 2); } + #[test] + fn remote_bundles_leave_exec_capable_harness_keys_behind() { + let dir = tempfile::tempdir().unwrap(); + let p = paths(dir.path()); + put( + &p.config, + json!({ + "theme": "ocean", + "harnesses": {"grok": {"program": "/tmp/evil"}}, + "custom_harnesses": [{"id": "x", "program": "/tmp/evil"}], + }), + ); + + let (backup, warnings) = export(&p, Scope::Backup); + assert!(warnings.is_empty(), "{warnings:?}"); + assert!(backup["config"].get("harnesses").is_some()); + assert!(backup["config"].get("custom_harnesses").is_some()); + + let (remote, warnings) = export(&p, Scope::Remote); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("harnesses"), "{warnings:?}"); + assert!(remote["config"].get("harnesses").is_none()); + assert!(remote["config"].get("custom_harnesses").is_none()); + assert_eq!(remote["config"]["theme"], "ocean"); + } + #[test] fn missing_files_are_no_section_and_a_broken_one_says_so() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/nebula-tui/src/claude_catalogue.rs b/crates/nebula-tui/src/claude_catalogue.rs index e12321a..2f3ee09 100644 --- a/crates/nebula-tui/src/claude_catalogue.rs +++ b/crates/nebula-tui/src/claude_catalogue.rs @@ -32,7 +32,12 @@ use std::path::{Path, PathBuf}; use std::sync::{OnceLock, RwLock}; -use crate::config::{CLAUDE_MODELS, DEFAULT_CHOICE}; +use crate::config::DEFAULT_CHOICE; + +/// The built-in Claude model aliases, [`DEFAULT_CHOICE`] first: what the +/// pickers offer when neither CONFIG.JSON's `claude_models` nor Claude +/// Code's own `availableModels` allowlist names anything. +pub const CLAUDE_MODELS: &[&str] = &[DEFAULT_CHOICE, "fable", "opus", "sonnet", "haiku"]; /// The settings key Claude Code reads its allowlist from. const AVAILABLE_MODELS_KEY: &str = "availableModels"; diff --git a/crates/nebula-tui/src/config.rs b/crates/nebula-tui/src/config.rs index ff6c331..76baa35 100644 --- a/crates/nebula-tui/src/config.rs +++ b/crates/nebula-tui/src/config.rs @@ -12,6 +12,7 @@ //! portable file. use nebula_core::AgentKind; +use nebula_core::harness::{CustomHarness, HarnessDescriptor}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; @@ -70,106 +71,207 @@ pub const DEFAULT_CHOICE: &str = "default"; /// `""`, never this word. pub const AUTO_CHOICE: &str = "auto"; -/// Model/effort choices for the new-session submenus and the settings -/// overlay. [`DEFAULT_CHOICE`] everywhere means "don't pass the flag — let -/// the CLI pick" and is what the daemon sees as None. `CLAUDE_MODELS` is -/// the built-in alias list; what the pickers show is -/// `claude_catalogue::models()`, which swaps it for `claude_models` in -/// CONFIG.JSON or Claude Code's own `availableModels` allowlist. -pub const CLAUDE_MODELS: &[&str] = &[DEFAULT_CHOICE, "fable", "opus", "sonnet", "haiku"]; -pub const CLAUDE_EFFORTS: &[&str] = &[DEFAULT_CHOICE, "low", "medium", "high", "xhigh", "max"]; -pub const CODEX_MODELS: &[&str] = &[ - DEFAULT_CHOICE, - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", - "gpt-5.5", -]; -pub const CODEX_EFFORTS: &[&str] = &[DEFAULT_CHOICE, "minimal", "low", "medium", "high", "xhigh"]; -/// Pi's `--model` takes a fuzzy pattern across every provider it has -/// credentials for (`opus` finds `anthropic/claude-opus-…`), so the list is -/// families, not ids; a hand-edited `provider/id` passes through verbatim. -pub const PI_MODELS: &[&str] = &[DEFAULT_CHOICE, "opus", "sonnet", "haiku", "gpt-5.5"]; -/// Pi's `--thinking` levels, in the CLI's own order. -pub const PI_EFFORTS: &[&str] = &[ - DEFAULT_CHOICE, - "off", - "minimal", - "low", - "medium", - "high", - "xhigh", - "max", -]; +/// The static model/effort lists live in the core registry table now +/// ([`nebula_core::harness::builtin`]); what the pickers show is built +/// below from the effective descriptor, so a `harnesses` override renames +/// the rows everywhere at once. Claude's models still come from +/// `claude_catalogue.rs` at runtime — CONFIG.JSON's `claude_models`, else +/// Claude Code's own `availableModels`, else the aliases — and Cursor's +/// from its catalogue (a seed plus a cached `cursor-agent --list-models`). + +/// The `quick_prompt_kind` choices: every built-in harness id, by the name +/// the config file stores. Derived from the registry table rather than +/// spelled out, so a new built-in joins the cycle without a second edit. +pub fn agent_kind_names() -> Vec { + nebula_core::harness::builtins() + .iter() + .map(|descriptor| descriptor.id.clone()) + .collect() +} -/// The `quick_prompt_kind` choices — every AGENT KIND, by the name the -/// config file stores. Spelled out rather than derived because -/// [`cycle_choice`] works over `&'static [&'static str]`; -/// `quick_prompt_kinds_are_every_agent_kind` keeps it honest. -pub const AGENT_KIND_NAMES: &[&str] = &["claude", "codex", "cursor", "pi"]; - -/// Model choices for a session kind. Claude's come from -/// `claude_catalogue.rs`: CONFIG.JSON's `claude_models`, else Claude Code's -/// `availableModels`, else [`CLAUDE_MODELS`]. Cursor's come from the CURSOR -/// CATALOGUE (`cursor_catalogue.rs`): a seed plus a cached -/// `cursor-agent --list-models`. -pub fn model_choices(kind: AgentKind) -> &'static [&'static str] { - match kind { - AgentKind::Claude => crate::claude_catalogue::models(), - AgentKind::Codex => CODEX_MODELS, - AgentKind::Cursor => crate::cursor_catalogue::models(), - AgentKind::Pi => PI_MODELS, - } +/// The model rows for a harness: [`DEFAULT_CHOICE`] first ("don't pass the +/// flag — let the CLI pick", what the daemon sees as None), then the +/// runtime catalogue (Claude/Cursor, already headed) or the descriptor's +/// static list. Pi's `--model` takes a fuzzy pattern across providers, so +/// its list is families, not ids; a hand-edited `provider/id` passes +/// through verbatim. +pub fn model_choices(kind: AgentKind, custom: Option<&str>) -> Vec { + model_choices_in(&describe(kind, custom)) } -/// Effort choices for a session kind given its chosen model (None or -/// "default" = the CLI's pick). Claude and Codex take any effort with any -/// model; Cursor's list follows the family (`-fast` variants ride in the -/// effort, `high-fast`), leads with "default" only when the bare family id -/// exists, and is empty — no Effort row, no effort submenu — when the model -/// is unset or the family has no effort variants (`auto`). -pub fn effort_choices(kind: AgentKind, model: Option<&str>) -> &'static [&'static str] { - match kind { - AgentKind::Claude => CLAUDE_EFFORTS, - AgentKind::Codex => CODEX_EFFORTS, - AgentKind::Cursor => crate::cursor_catalogue::efforts(model), - AgentKind::Pi => PI_EFFORTS, +/// [`model_choices`] against an explicit descriptor, for callers that +/// already resolved one (the Agents tab, the launch sites). +pub fn model_choices_in(descriptor: &nebula_core::harness::HarnessDescriptor) -> Vec { + match descriptor.model.catalog { + Some(nebula_core::harness::HarnessCatalog::Claude) => crate::claude_catalogue::models() + .iter() + .map(|s| s.to_string()) + .collect(), + Some(nebula_core::harness::HarnessCatalog::Cursor) => crate::cursor_catalogue::models() + .iter() + .map(|s| s.to_string()) + .collect(), + None => headed( + descriptor.model.models.iter().map(|entry| entry.id.clone()).collect(), + ), } } +/// Effort rows for a harness given its chosen model (None or "default" = +/// the CLI's pick). Empty — no Effort row, no effort submenu — while the +/// harness offers no effort. Cursor's list follows the family (`-fast` +/// variants ride in the effort, `high-fast`); any other harness takes its +/// static list with any model. +pub fn effort_choices( + kind: AgentKind, + model: Option<&str>, + custom: Option<&str>, +) -> Vec { + effort_choices_in(&describe(kind, custom), model) +} + +/// [`effort_choices`] against an explicit descriptor, for callers that +/// already resolved one (the Agents tab, the launch sites). +pub fn effort_choices_in( + descriptor: &nebula_core::harness::HarnessDescriptor, + model: Option<&str>, +) -> Vec { + if !descriptor.effort.offered { + return Vec::new(); + } + if descriptor.model.catalog == Some(nebula_core::harness::HarnessCatalog::Cursor) { + return crate::cursor_catalogue::efforts(model) + .iter() + .map(|s| s.to_string()) + .collect(); + } + headed(descriptor.effort.efforts.clone()) +} + +/// [`DEFAULT_CHOICE`] heading a choice list, without doubling a default +/// the source already carries. +fn headed(mut rest: Vec) -> Vec { + rest.retain(|choice| !choice.eq_ignore_ascii_case(DEFAULT_CHOICE)); + let mut out = vec![DEFAULT_CHOICE.to_string()]; + out.append(&mut rest); + out +} + /// Whether `value` is one of `choices`, case-insensitively and trimmed — /// how a form decides a saved or cycled choice still has a row. -pub(crate) fn fits(value: &str, choices: &[&str]) -> bool { - choices.iter().any(|c| c.eq_ignore_ascii_case(value.trim())) +pub(crate) fn fits(value: &str, choices: &[impl AsRef]) -> bool { + choices + .iter() + .any(|c| c.as_ref().eq_ignore_ascii_case(value.trim())) } -/// The effort to launch `kind` with, given its model and the picked effort. -/// Claude, Codex and Pi pass through. For Cursor: no family → None; an effort -/// the family ships → itself; anything else ("default", unset, a suffix the -/// family lacks) → None when the bare family id exists, otherwise the -/// family's fallback (`high` > `medium` > first) — most families have no -/// bare id, and `--model claude-fable-5` alone is refused at spawn. -pub fn fit_effort(kind: AgentKind, model: Option<&str>, effort: Option) -> Option { - match kind { - AgentKind::Claude | AgentKind::Codex | AgentKind::Pi => effort, - AgentKind::Cursor => { - let family = model - .map(str::trim) - .filter(|m| !m.eq_ignore_ascii_case(DEFAULT_CHOICE))?; - let choices = crate::cursor_catalogue::efforts(Some(family)); - if choices.is_empty() { - return None; - } - let picked = effort - .map(|e| e.trim().to_ascii_lowercase()) - .filter(|e| e != DEFAULT_CHOICE); - match picked { - Some(e) if fits(&e, choices) => Some(e), - _ if choices[0] == DEFAULT_CHOICE => None, - _ => crate::cursor_catalogue::fallback_effort(family).map(String::from), - } +/// Step `current` through an owned choice list, wrapping around; a value +/// off the list steps onto it. The owned twin of [`cycle_choice`], for +/// rows the registry builds at runtime. +pub(crate) fn cycle_owned(current: &str, choices: &[String], delta: i32) -> String { + if choices.is_empty() { + return current.to_string(); + } + let n = choices.len() as i32; + let pos = choices + .iter() + .position(|c| c.eq_ignore_ascii_case(current.trim())) + .unwrap_or(0) as i32; + choices[(pos + delta).rem_euclid(n) as usize].clone() +} + +/// The effort to launch with, given the harness, its model and the picked +/// effort. Most harnesses pass through. A composing harness (Cursor's +/// family-suffix shape: `--model -`) fits instead: no +/// family → None; an effort the family ships → itself; anything else +/// ("default", unset, a suffix the family lacks) → None when the bare +/// family id exists, otherwise the family's fallback — most families have +/// no bare id, and a bare `--model claude-fable-5` is refused at spawn. +pub fn fit_effort( + kind: AgentKind, + model: Option<&str>, + effort: Option, + custom: Option<&str>, +) -> Option { + fit_effort_in(&describe(kind, custom), model, effort) +} + +/// [`fit_effort`] against an explicit descriptor, for callers that +/// already resolved one (the Agents tab, the launch sites). +pub fn fit_effort_in( + descriptor: &nebula_core::harness::HarnessDescriptor, + model: Option<&str>, + effort: Option, +) -> Option { + if !descriptor.compose_model_effort { + return effort; + } + let family = model + .map(str::trim) + .filter(|m| !m.eq_ignore_ascii_case(DEFAULT_CHOICE))?; + if descriptor.model.catalog == Some(nebula_core::harness::HarnessCatalog::Cursor) { + let choices = crate::cursor_catalogue::efforts(Some(family)); + if choices.is_empty() { + return None; } + let picked = effort + .map(|e| e.trim().to_ascii_lowercase()) + .filter(|e| e != DEFAULT_CHOICE); + return match picked { + Some(e) if fits(&e, choices) => Some(e), + _ if choices[0] == DEFAULT_CHOICE => None, + _ => crate::cursor_catalogue::fallback_effort(family).map(String::from), + }; } + if descriptor.effort.efforts.is_empty() { + return None; + } + let picked = effort + .map(|e| e.trim().to_ascii_lowercase()) + .filter(|e| e != DEFAULT_CHOICE); + match picked { + Some(e) if fits(&e, &descriptor.effort.efforts) => Some(e), + _ => static_fallback_effort(&descriptor.effort.efforts).map(String::from), + } +} + +/// The fallback effort for a static list: `high`, else `medium`, else the +/// first the harness ships. +fn static_fallback_effort(efforts: &[String]) -> Option<&str> { + efforts + .iter() + .find(|e| e.as_str() == "high") + .or_else(|| efforts.iter().find(|e| e.as_str() == "medium")) + .or_else(|| efforts.first()) + .map(String::as_str) +} + +/// The effective descriptor `(kind, custom)` reads as: the registry row +/// with the `harnesses` map, the legacy list entry, and the legacy +/// per-harness keys folded in. Loads the config fresh, like every other +/// reader here. A broken entry still resolves (the picker, not the read, +/// hides it); launches refuse it with its reason. An id the registry no +/// longer names degrades to a placeholder under its own name, so rows +/// outliving their entry still render. +fn describe(kind: AgentKind, custom: Option<&str>) -> nebula_core::harness::HarnessDescriptor { + let id = match kind { + AgentKind::Custom => custom.unwrap_or_default().trim(), + _ => kind.as_str(), + }; + let cfg = Config::load(); + if let Some(descriptor) = cfg.harness_registry().into_iter().find(|entry| entry.id == id) { + return descriptor; + } + nebula_core::harness::CustomHarness { + id: id.to_string(), + label: String::new(), + program: id.to_string(), + enabled: true, + model: "default".into(), + model_flag: "--model".into(), + hooks: None, + } + .as_descriptor() } /// One setting row in the overlay; rows live inside a [`SettingsTab`]. @@ -186,13 +288,40 @@ pub struct SettingSpec { /// What a tab shows. Ordinary tabs are a list of value settings; the /// Hotkeys tab is generated from [`crate::keymap::ACTIONS`] instead, so a -/// new action shows up there without being declared twice. +/// new action shows up there without being declared twice — and the Agents +/// tab is generated from the harness registry, so a new CLI shows up +/// there without being declared twice. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TabBody { Values(&'static [SettingSpec]), Hotkeys, + Agents, } +/// The Agents tab's static head: the cross-harness quick-prompt rows. The +/// per-harness sections below them are generated from the registry (see +/// [`Config::agent_rows`]). +pub const AGENTS_HEAD: &[SettingSpec] = &[ + SettingSpec { + kind: SettingKind::QuickPromptKind, + label: "Agent", + hint: "Harness the quick prompt hotkey launches, with that kind's model/effort", + group: "Quick prompt", + }, + SettingSpec { + kind: SettingKind::QuickPromptFocus, + label: "Focus", + hint: "Enter the new session's terminal on launch (off = just select its row)", + group: "Quick prompt", + }, + SettingSpec { + kind: SettingKind::HideUninstalledHarnesses, + label: "Hide missing CLIs", + hint: "List only harnesses found on PATH in the New session picker (daemon still checks at launch)", + group: "Quick prompt", + }, +]; + /// One tab of the settings overlay. Selection indices are per-tab: within /// a `Values` tab they index its settings, within `Hotkeys` they index /// `keymap::ACTIONS`. @@ -230,18 +359,28 @@ pub enum SettingKind { RecentPrompts, RecentPromptsCount, ShowKeyCombos, - ClaudeEnabled, - ClaudeModel, - ClaudeEffort, - CodexEnabled, - CodexModel, - CodexEffort, - CursorEnabled, - CursorModel, - CursorEffort, - PiEnabled, - PiModel, - PiEffort, + HideUninstalledHarnesses, +} + +/// One harness field row in the Agents tab. The tab renders one section +/// per registry entry — built-ins, legacy customs and `harnesses` map ids +/// alike — with an Enabled row, a Model row, and an Effort row while the +/// harness offers effort. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HarnessField { + Enabled, + Model, + Effort, +} + +impl HarnessField { + pub fn label(self) -> &'static str { + match self { + HarnessField::Enabled => "Enabled", + HarnessField::Model => "Model", + HarnessField::Effort => "Effort", + } + } } impl SettingKind { @@ -393,97 +532,13 @@ pub const SETTINGS_TABS: &[SettingsTab] = &[ }, ]), }, - // Grouped per harness: the two cross-kind quick prompt rows first, - // then one section per agent kind holding its enabled toggle and - // its model / effort defaults, in that order. + // Generated from the harness registry: the static quick-prompt head + // first, then one section per entry holding its enabled toggle and + // its model / effort defaults, in registry order. A new CLI in the + // `harnesses` map grows its own section with no code change. SettingsTab { title: "Agents", - body: TabBody::Values(&[ - SettingSpec { - kind: SettingKind::QuickPromptKind, - label: "Agent", - hint: "Harness the quick prompt hotkey launches, with that kind's model/effort", - group: "Quick prompt", - }, - SettingSpec { - kind: SettingKind::QuickPromptFocus, - label: "Focus", - hint: "Enter the new session's terminal on launch (off = just select its row)", - group: "Quick prompt", - }, - SettingSpec { - kind: SettingKind::ClaudeEnabled, - label: "Enabled", - hint: "Offer Claude in the New session picker (off hides it; existing sessions keep running)", - group: "Claude", - }, - SettingSpec { - kind: SettingKind::ClaudeModel, - label: "Model", - hint: "Default model; rows follow Claude's availableModels or config.json claude_models", - group: "Claude", - }, - SettingSpec { - kind: SettingKind::ClaudeEffort, - label: "Effort", - hint: "Default reasoning effort for new Claude sessions", - group: "Claude", - }, - SettingSpec { - kind: SettingKind::CodexEnabled, - label: "Enabled", - hint: "Offer Codex in the New session picker (off hides it; existing sessions keep running)", - group: "Codex", - }, - SettingSpec { - kind: SettingKind::CodexModel, - label: "Model", - hint: "Default model for new Codex sessions (default = CLI's pick)", - group: "Codex", - }, - SettingSpec { - kind: SettingKind::CodexEffort, - label: "Effort", - hint: "Default reasoning effort for new Codex sessions", - group: "Codex", - }, - SettingSpec { - kind: SettingKind::CursorEnabled, - label: "Enabled", - hint: "Offer Cursor in the New session picker (off hides it; existing sessions keep running)", - group: "Cursor", - }, - SettingSpec { - kind: SettingKind::CursorModel, - label: "Model", - hint: "Default model family for new Cursor sessions (default = CLI's pick)", - group: "Cursor", - }, - SettingSpec { - kind: SettingKind::CursorEffort, - label: "Effort", - hint: "Effort (and -fast) variant of the chosen Cursor model; n/a while it is default or auto", - group: "Cursor", - }, - SettingSpec { - kind: SettingKind::PiEnabled, - label: "Enabled", - hint: "Offer Pi in the New session picker (off hides it; existing sessions keep running)", - group: "Pi", - }, - SettingSpec { - kind: SettingKind::PiModel, - label: "Model", - hint: "Default --model pattern for new Pi sessions (default = CLI's pick)", - group: "Pi", - }, - SettingSpec { - kind: SettingKind::PiEffort, - label: "Effort", - hint: "Default --thinking level for new Pi sessions", - group: "Pi", - }, - ]), + body: TabBody::Agents, }, // Behaviors that change how the tree is worked, off by default until // they have earned a tab of their own. Before Hotkeys, which stays @@ -531,46 +586,74 @@ pub fn hotkeys_tab() -> usize { .expect("SETTINGS_TABS declares a Hotkeys tab") } +/// Index of the Agents tab, generated from the harness registry. +pub fn agents_tab() -> usize { + SETTINGS_TABS + .iter() + .position(|t| t.body == TabBody::Agents) + .expect("SETTINGS_TABS declares an Agents tab") +} + pub fn tab_count() -> usize { SETTINGS_TABS.len() } -/// The value settings of a tab; empty for the Hotkeys tab. +/// The static value settings of a tab: the declared list, or the Agents +/// head (its per-harness sections are generated — see +/// [`Config::agent_rows`]). Empty for the Hotkeys tab. pub fn tab_settings(tab: usize) -> &'static [SettingSpec] { match SETTINGS_TABS.get(tab).map(|t| t.body) { Some(TabBody::Values(settings)) => settings, + Some(TabBody::Agents) => AGENTS_HEAD, _ => &[], } } -/// How many selectable rows a tab holds. +/// How many selectable rows a tab holds. The Agents tab reads the +/// registry, so a new CLI grows it without a code change. pub fn tab_len(tab: usize) -> usize { match SETTINGS_TABS.get(tab).map(|t| t.body) { Some(TabBody::Values(settings)) => settings.len(), Some(TabBody::Hotkeys) => crate::keymap::ACTIONS.len(), + Some(TabBody::Agents) => AGENTS_HEAD.len() + Config::load().agent_rows().len(), None => 0, } } -/// The value setting at a tab-local index, if the tab has one there. +/// The static value setting at a tab-local index, if the tab declares one +/// there: the full list on ordinary tabs, the head on the Agents tab +/// (its harness rows resolve through [`Config::agent_row`]), never on +/// Hotkeys. pub fn setting_at(tab: usize, index: usize) -> Option<&'static SettingSpec> { tab_settings(tab).get(index) } -/// Where a setting lives, as `(tab, row)`. The overlay addresses settings -/// by position, so anything that wants to talk about one by name — tests, -/// and anything that ever jumps the cursor to a named setting — goes -/// through here rather than hardcoding an index. +/// Where a static setting lives, as `(tab, row)`. The overlay addresses +/// settings by position, so anything that wants to talk about one by name +/// — tests, and anything that ever jumps the cursor to a named setting — +/// goes through here rather than hardcoding an index. Harness rows locate +/// through [`locate_agent`]. pub fn locate(kind: SettingKind) -> Option<(usize, usize)> { SETTINGS_TABS.iter().enumerate().find_map(|(t, tab)| { match tab.body { TabBody::Values(settings) => settings.iter().position(|s| s.kind == kind), + TabBody::Agents => AGENTS_HEAD.iter().position(|s| s.kind == kind), TabBody::Hotkeys => None, } .map(|i| (t, i)) }) } +/// Where an Agents tab harness row lives, as `(tab, row)`. Reads the +/// registry, like the tab itself. +pub fn locate_agent(id: &str, field: HarnessField) -> Option<(usize, usize)> { + let tab = agents_tab(); + let rows = Config::load().agent_rows(); + rows.iter() + .position(|(row_id, row_field)| row_id == id && *row_field == field) + .map(|i| (tab, AGENTS_HEAD.len() + i)) +} + /// The row declared for `kind`, wherever it sits — for anything that /// wants its label or hint by name (the typed-row prompt's title). pub fn spec_for(kind: SettingKind) -> Option<&'static SettingSpec> { @@ -579,7 +662,9 @@ pub fn spec_for(kind: SettingKind) -> Option<&'static SettingSpec> { .find(|spec| spec.kind == kind) } -/// Every value setting, tab by tab, for coverage checks. +/// Every static value setting, tab by tab, for coverage checks. The +/// Agents tab contributes its head; its harness rows are covered through +/// [`Config::agent_rows`]. pub fn all_settings() -> impl Iterator { SETTINGS_TABS.iter().enumerate().flat_map(|(t, tab)| { tab_settings(t).iter().enumerate().map(move |(i, s)| { @@ -590,20 +675,27 @@ pub fn all_settings() -> impl Iterator &'static str { +/// The Agents tab reads the registry for its harness rows. +pub fn hint_at(tab: usize, index: usize) -> String { match SETTINGS_TABS.get(tab).map(|t| t.body) { - Some(TabBody::Values(settings)) => settings.get(index).map(|s| s.hint).unwrap_or(""), - Some(TabBody::Hotkeys) => crate::keymap::spec_at(index).map(|s| s.hint).unwrap_or(""), - None => "", + Some(TabBody::Values(settings)) => { + settings.get(index).map(|s| s.hint).unwrap_or("").to_string() + } + Some(TabBody::Hotkeys) => crate::keymap::spec_at(index) + .map(|s| s.hint) + .unwrap_or("") + .to_string(), + Some(TabBody::Agents) => Config::load().agent_hint_by_index(index), + None => String::new(), } } /// One terminal row of the settings overlay body, in display order. /// Shared by the renderer and mouse hit-testing so they can't drift. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum SettingsRow { Blank, - Header(&'static str), + Header(String), /// Label + value line for the value setting at this tab-local index. Setting(usize), /// Label + chord list for `keymap::ACTIONS[index]`. @@ -613,9 +705,9 @@ pub enum SettingsRow { impl SettingsRow { /// The tab-local selection index this row stands for, if it's one the /// cursor can land on. - pub fn index(self) -> Option { + pub fn index(&self) -> Option { match self { - SettingsRow::Setting(i) | SettingsRow::Hotkey(i) => Some(i), + SettingsRow::Setting(i) | SettingsRow::Hotkey(i) => Some(*i), _ => None, } } @@ -623,13 +715,23 @@ impl SettingsRow { pub fn settings_rows(tab: usize) -> Vec { match SETTINGS_TABS.get(tab).map(|t| t.body) { - Some(TabBody::Values(settings)) => { - grouped(settings.iter().map(|s| s.group), SettingsRow::Setting) - } + Some(TabBody::Values(settings)) => grouped( + settings.iter().map(|s| s.group.to_string()), + SettingsRow::Setting, + ), Some(TabBody::Hotkeys) => grouped( - crate::keymap::ACTIONS.iter().map(|s| s.group), + crate::keymap::ACTIONS.iter().map(|s| s.group.to_string()), SettingsRow::Hotkey, ), + Some(TabBody::Agents) => { + let cfg = Config::load(); + let head = AGENTS_HEAD.iter().map(|s| s.group.to_string()); + let rows = cfg.agent_rows(); + let groups = rows.iter().map(|(id, _)| { + cfg.effective_harness_by_id(id).display_label().to_string() + }); + grouped(head.chain(groups), SettingsRow::Setting) + } None => Vec::new(), } } @@ -640,17 +742,17 @@ pub fn settings_rows(tab: usize) -> Vec { /// whose group is empty — so a tab with no groups is the bare list it /// always was. fn grouped( - groups: impl Iterator, + groups: impl Iterator, row: fn(usize) -> SettingsRow, ) -> Vec { let mut rows = Vec::new(); - let mut current: Option<&'static str> = None; + let mut current: Option = None; for (i, group) in groups.enumerate() { - if !group.is_empty() && current != Some(group) { + if !group.is_empty() && current.as_deref() != Some(&group) { if !rows.is_empty() { rows.push(SettingsRow::Blank); } - rows.push(SettingsRow::Header(group)); + rows.push(SettingsRow::Header(group.clone())); current = Some(group); } rows.push(row(i)); @@ -825,6 +927,10 @@ pub struct Config { /// Pi's pair: a `--model` pattern and a `--thinking` level. pub pi_model: String, pub pi_effort: String, + /// Muse's `--model` id. `muse_effort` is reserved until the CLI + /// documents a reasoning flag; it stores but sends nothing. + pub muse_model: String, + pub muse_effort: String, /// Which AGENT KINDS the NEW SESSION PICKER offers. Off leaves that /// harness out of the picker and the PR SESSION picker (and, for /// Claude, out of the standing PREWARM POOL slot); sessions that already @@ -834,6 +940,28 @@ pub struct Config { pub codex_enabled: bool, pub cursor_enabled: bool, pub pi_enabled: bool, + pub muse_enabled: bool, + /// When on, the New session picker lists only enabled harnesses whose + /// CLI is found on this machine's PATH. Off by default: a login shell + /// (mise, brew shims) can see CLIs a plain PATH lookup misses, and the + /// daemon re-checks through the login shell at launch anyway. + pub hide_uninstalled_harnesses: bool, + /// User-defined harnesses (`custom_harnesses` in config.json): offered + /// in the New session picker after the built-ins when enabled, launched + /// with the entry's program and model flag, with process-based status + /// unless the entry names a hook dialect. Empty by default. Legacy: + /// new harnesses belong in `harnesses` as full descriptors, where + /// they also gain resume, effort, system-prompt and hook-dialect rows. + pub custom_harnesses: Vec, + /// Per-harness deltas over the compiled-in registry (`harnesses` in + /// config.json): disable a built-in, repoint a program, rename a + /// flag, or define a whole new CLI. Merged by + /// [`Config::harness_registry`]; the Agents tab, the `n` picker, the + /// `e` presets, spawn and hooks all read the merged rows. A hand edit + /// that breaks one entry refuses its launches with the reason, never + /// the whole file (see `nebula_core::settings`). + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub harnesses: BTreeMap, /// Which AGENT KIND the QUICK PROMPT hotkey launches. Its model and /// effort come from that kind's own defaults above, so the setting is /// one name, not a third model/effort pair. Read through @@ -898,10 +1026,16 @@ impl Default for Config { cursor_effort: DEFAULT_CHOICE.into(), pi_model: DEFAULT_CHOICE.into(), pi_effort: DEFAULT_CHOICE.into(), + muse_model: DEFAULT_CHOICE.into(), + muse_effort: DEFAULT_CHOICE.into(), claude_enabled: true, codex_enabled: true, cursor_enabled: true, pi_enabled: true, + muse_enabled: true, + hide_uninstalled_harnesses: false, + custom_harnesses: Vec::new(), + harnesses: BTreeMap::new(), quick_prompt_kind: AgentKind::Claude.as_str().into(), quick_prompt_focus: false, keybindings: BTreeMap::new(), @@ -1024,39 +1158,136 @@ impl Config { ) } + /// The effective registry this config reads: the compiled-in known + /// harnesses with the `harnesses` map applied, then the legacy + /// `custom_harnesses` list, then map-only new ids — plus, for + /// built-ins, the legacy per-harness keys (`claude_model`, + /// `codex_enabled`, …) wherever the map stays silent on that field. + /// The map wins where both speak; the Agents tab writes the legacy + /// keys for built-ins, so its edits apply without a migration. + pub fn harness_registry(&self) -> Vec { + let mut all = nebula_core::harness::registry(&self.harnesses, &self.custom_harnesses); + for entry in &mut all { + if nebula_core::harness::builtin(&entry.id).is_none() { + continue; + } + let over = self.harnesses.get(&entry.id); + let (legacy_enabled, legacy_model, legacy_effort) = + self.legacy_harness_fields(&entry.id); + if over.and_then(|o| o.enabled).is_none() { + if let Some(enabled) = legacy_enabled { + entry.enabled = enabled; + } + } + if over.and_then(|o| o.model_default.clone()).is_none() { + if let Some(default) = legacy_model { + entry.model.default = default; + } + } + if over.and_then(|o| o.effort_default.clone()).is_none() { + if let Some(default) = legacy_effort { + entry.effort.default = default; + } + } + } + all + } + + /// The legacy per-harness keys for a built-in id, as + /// `(enabled, model, effort)` — `Some` only where the file differs + /// from the default, i.e. where the user said something. Newer than + /// the table, older than the `harnesses` map. + fn legacy_harness_fields(&self, id: &str) -> (Option, Option, Option) { + let (enabled, model, effort) = match id { + "claude" => (&self.claude_enabled, &self.claude_model, &self.claude_effort), + "codex" => (&self.codex_enabled, &self.codex_model, &self.codex_effort), + "cursor" => (&self.cursor_enabled, &self.cursor_model, &self.cursor_effort), + "pi" => (&self.pi_enabled, &self.pi_model, &self.pi_effort), + "muse" => (&self.muse_enabled, &self.muse_model, &self.muse_effort), + _ => return (None, None, None), + }; + ( + (!enabled).then_some(false), + non_default(model), + non_default(effort), + ) + } + + /// The effective built-in descriptor `kind` reads as, regardless of + /// whether the entry is enabled or valid (the picker hides broken + /// rows; reads degrade gracefully). + fn builtin_descriptor(&self, kind: AgentKind) -> HarnessDescriptor { + let id = kind.as_str(); + self.harness_registry() + .into_iter() + .find(|entry| entry.id == id) + .or_else(|| nebula_core::harness::builtin(id)) + .expect("every built-in AgentKind describes") + } + + /// The effective descriptor `(kind, custom)` reads as: the registry + /// row, or a placeholder under its own name when the registry no + /// longer names the id, so rows outliving their entry still render. + pub fn effective_harness(&self, kind: AgentKind, custom: Option<&str>) -> HarnessDescriptor { + let id = match kind { + AgentKind::Custom => custom.unwrap_or_default().trim(), + _ => kind.as_str(), + }; + if let Some(descriptor) = self.harness_registry().into_iter().find(|entry| entry.id == id) + { + return descriptor; + } + CustomHarness { + id: id.to_string(), + label: String::new(), + program: id.to_string(), + enabled: true, + model: "default".into(), + model_flag: "--model".into(), + hooks: None, + } + .as_descriptor() + } + /// The configured default model for new sessions of `kind`, as the /// daemon wants it: None = "default" = don't pass the flag. pub fn default_model(&self, kind: AgentKind) -> Option { - let value = match kind { - AgentKind::Claude => &self.claude_model, - AgentKind::Codex => &self.codex_model, - AgentKind::Cursor => &self.cursor_model, - AgentKind::Pi => &self.pi_model, - }; - non_default(value) + // Custom defaults resolve from the entry at the launch site, + // where the id is known — never through this kind-only helper. + if kind == AgentKind::Custom { + return None; + } + self.builtin_descriptor(kind).default_model().map(str::to_string) } /// The configured default effort for new sessions of `kind`; - /// None = "default" = don't pass the flag. For Cursor an effort the - /// configured family does not ship is None too ([`fit_effort`]). + /// None = "default" = don't pass the flag. A composing harness fits + /// the effort against its configured family ([`fit_effort`]). A + /// reserved effort (stored, like Muse's, but with no flag mapped yet) + /// is None whatever the file holds — spawn would drop it anyway. pub fn default_effort(&self, kind: AgentKind) -> Option { - let value = match kind { - AgentKind::Claude => &self.claude_effort, - AgentKind::Codex => &self.codex_effort, - AgentKind::Cursor => &self.cursor_effort, - AgentKind::Pi => &self.pi_effort, - }; - fit_effort(kind, Some(&self.cursor_model), non_default(value)) + if kind == AgentKind::Custom { + return None; + } + let descriptor = self.builtin_descriptor(kind); + if descriptor.effort.flag.is_none() + && descriptor.effort.config_key.is_none() + && !descriptor.compose_model_effort + { + return None; + } + let model = descriptor.default_model().map(str::to_string); + let effort = descriptor.default_effort().map(str::to_string); + fit_effort_in(&descriptor, model.as_deref(), effort) } /// Whether the NEW SESSION PICKER offers `kind` at all. pub fn kind_enabled(&self, kind: AgentKind) -> bool { - match kind { - AgentKind::Claude => self.claude_enabled, - AgentKind::Codex => self.codex_enabled, - AgentKind::Cursor => self.cursor_enabled, - AgentKind::Pi => self.pi_enabled, + if kind == AgentKind::Custom { + // A bare Custom kind is never enabled: entries gate themselves. + return false; } + self.builtin_descriptor(kind).enabled } /// The AGENT KINDS the picker lists, in `AgentKind::ALL` order. Empty @@ -1069,6 +1300,306 @@ impl Config { .collect() } + /// The kinds the picker shows: enabled, and when + /// `hide_uninstalled_harnesses` is on, only those whose CLI is found + /// on PATH right now. The daemon stays authoritative at launch (it + /// probes through the login shell, which sees more than PATH). + pub fn visible_kinds(&self) -> Vec { + let all = self.harness_registry(); + let kinds = self.enabled_kinds(); + if !self.hide_uninstalled_harnesses { + return kinds; + } + kinds + .into_iter() + .filter(|kind| { + all.iter() + .find(|entry| entry.id == kind.as_str()) + .is_some_and(|entry| program_installed(&entry.program)) + }) + .collect() + } + + /// Every harness the picker and presets offer, in registry order: + /// `(kind, None)` for built-ins, `(Custom, Some(id))` for customs. + /// Disabled and broken entries are out (broken ones surface in the + /// Agents tab with their reason); under `hide_uninstalled_harnesses` + /// so is anything whose program is missing from PATH. The daemon + /// stays authoritative at launch. + pub fn offered_harnesses(&self) -> Vec<(AgentKind, Option)> { + let all = self.harness_registry(); + nebula_core::harness::usable(&all) + .into_iter() + .filter(|entry| !self.hide_uninstalled_harnesses || program_installed(&entry.program)) + .map(|entry| match AgentKind::parse(&entry.id) { + Some(kind) => (kind, None), + None => (AgentKind::Custom, Some(entry.id.clone())), + }) + .collect() + } + + /// Whether a preset may launch: its harness's Agents tab switch, + /// enabled and valid. + pub fn preset_harness_usable(&self, preset: &crate::agent_presets::AgentPreset) -> bool { + let id = match preset.kind { + AgentKind::Custom => preset.custom_harness.clone().unwrap_or_default(), + _ => preset.kind.as_str().to_string(), + }; + self.harness_registry() + .iter() + .find(|entry| entry.id == id) + .is_some_and(|entry| entry.enabled && entry.problem().is_none()) + } + + /// The effective descriptor for a registry id, or a placeholder under + /// its own name when the registry no longer names it, so rows + /// outliving their entry still render. + pub fn effective_harness_by_id(&self, id: &str) -> HarnessDescriptor { + if let Some(descriptor) = self.harness_registry().into_iter().find(|entry| entry.id == id) + { + return descriptor; + } + CustomHarness { + id: id.to_string(), + label: String::new(), + program: id.to_string(), + enabled: true, + model: "default".into(), + model_flag: "--model".into(), + hooks: None, + } + .as_descriptor() + } + + /// `(id, field)` rows below the Agents head, in registry order: every + /// entry, enabled or not, valid or not (broken rows show their reason + /// so they can be fixed); Effort only while the harness offers effort. + pub fn agent_rows(&self) -> Vec<(String, HarnessField)> { + let mut rows = Vec::new(); + for entry in self.harness_registry() { + rows.push((entry.id.clone(), HarnessField::Enabled)); + rows.push((entry.id.clone(), HarnessField::Model)); + if entry.effort.offered { + rows.push((entry.id.clone(), HarnessField::Effort)); + } + } + rows + } + + /// The harness row at a tab-local Agents index, or None while the + /// index lands on the static head. + pub fn agent_row(&self, index: usize) -> Option<(String, HarnessField)> { + self.agent_rows() + .into_iter() + .nth(index.checked_sub(AGENTS_HEAD.len())?) + } + + /// The value an Agents harness row shows. + pub fn agent_value(&self, id: &str, field: HarnessField) -> String { + let descriptor = self.effective_harness_by_id(id); + match field { + HarnessField::Enabled => on_off(descriptor.enabled).into(), + HarnessField::Model => descriptor.model.default.clone(), + HarnessField::Effort => { + let choices = effort_choices_in( + &descriptor, + descriptor.default_model().map(str::to_string).as_deref(), + ); + if choices.is_empty() { + "n/a".into() + } else { + descriptor.effort.default.clone() + } + } + } + } + + /// The hint an Agents harness row shows: what the row edits, with the + /// entry's problem appended while it is broken. + pub fn agent_hint(&self, id: &str, field: HarnessField) -> String { + let descriptor = self.effective_harness_by_id(id); + let label = descriptor.display_label(); + let mut hint = match field { + HarnessField::Enabled => format!( + "Offer {label} in the New session picker (off hides it; existing sessions keep running)" + ), + HarnessField::Model => match descriptor.model.catalog { + Some(nebula_core::harness::HarnessCatalog::Claude) => format!( + "Default model for new {label} sessions; rows follow Claude's availableModels or config.json claude_models" + ), + Some(nebula_core::harness::HarnessCatalog::Cursor) => format!( + "Default model family for new {label} sessions; rows follow cursor-agent --list-models" + ), + None => format!("Default model for new {label} sessions (default = CLI's pick)"), + }, + HarnessField::Effort => { + if descriptor.compose_model_effort { + format!( + "Effort (and -fast) variant of the chosen {label} model; n/a while it is default or auto" + ) + } else if let Some(flag) = descriptor.effort.flag.as_deref() { + format!("Default reasoning effort ({flag}) for new {label} sessions") + } else if let (Some(flag), Some(key)) = ( + descriptor.effort.config_flag.as_deref(), + descriptor.effort.config_key.as_deref(), + ) { + format!("Default reasoning effort ({flag} {key}=) for new {label} sessions") + } else { + format!("Reserved until the {label} CLI documents a reasoning flag (default = unset)") + } + } + }; + if let Some(problem) = descriptor.problem() { + hint.push_str(&format!(" — broken: {problem}")); + } + hint + } + + /// The hint for a tab-local Agents index: the head row's own hint, or + /// the harness row's. + pub fn agent_hint_by_index(&self, index: usize) -> String { + if let Some(spec) = AGENTS_HEAD.get(index) { + return spec.hint.to_string(); + } + match self.agent_row(index) { + Some((id, field)) => self.agent_hint(&id, field), + None => String::new(), + } + } + + /// Cycle an Agents harness row: toggle Enabled, step Model / Effort + /// through the rows the pickers offer. A composing harness refits its + /// effort against the new model, so the row never holds an id the CLI + /// would refuse. + pub fn cycle_agent_row(&mut self, id: &str, field: HarnessField, delta: i32) { + let Some(descriptor) = self + .harness_registry() + .into_iter() + .find(|entry| entry.id == id) + else { + return; + }; + let step = if delta == 0 { 1 } else { delta }; + match field { + HarnessField::Enabled => self.set_harness_enabled(id, !descriptor.enabled), + HarnessField::Model => { + let choices = model_choices_in(&descriptor); + let next = cycle_owned(&descriptor.model.default, &choices, step); + self.set_harness_model(id, next.clone()); + let descriptor = self.effective_harness_by_id(id); + if descriptor.compose_model_effort { + let choices = effort_choices_in(&descriptor, Some(&next)); + if !fits(&descriptor.effort.default, &choices) { + let fitted = fit_effort_in(&descriptor, Some(&next), None) + .unwrap_or_else(|| DEFAULT_CHOICE.into()); + self.set_harness_effort(id, fitted); + } + } + } + HarnessField::Effort => { + let choices = effort_choices_in( + &descriptor, + descriptor.default_model().map(str::to_string).as_deref(), + ); + if choices.is_empty() { + return; + } + let next = cycle_owned(&descriptor.effort.default, &choices, step); + self.set_harness_effort(id, next); + } + } + } + + /// Write an Enabled toggle: the `harnesses` map where it speaks, else + /// the legacy layer (the built-in switch, the list entry). + fn set_harness_enabled(&mut self, id: &str, enabled: bool) { + if self.harnesses.get(id).and_then(|o| o.enabled).is_some() { + self.harness_override_mut(id).enabled = Some(enabled); + return; + } + if nebula_core::harness::builtin(id).is_some() { + self.set_legacy_enabled(id, enabled); + return; + } + if let Some(entry) = self.custom_harnesses.iter_mut().find(|entry| entry.id == id) { + entry.enabled = enabled; + return; + } + self.harness_override_mut(id).enabled = Some(enabled); + } + + /// Write a Model default: the map where it speaks, else the legacy + /// layer (the built-in model key, the list entry's model). + fn set_harness_model(&mut self, id: &str, model: String) { + if self.harnesses.get(id).and_then(|o| o.model_default.clone()).is_some() { + self.harness_override_mut(id).model_default = Some(model); + return; + } + if nebula_core::harness::builtin(id).is_some() { + self.set_legacy_model(id, model); + return; + } + if let Some(entry) = self.custom_harnesses.iter_mut().find(|entry| entry.id == id) { + entry.model = model; + return; + } + self.harness_override_mut(id).model_default = Some(model); + } + + /// Write an Effort default: the map where it speaks, else the legacy + /// built-in effort key (legacy list entries hold no effort; the map + /// owns theirs). + fn set_harness_effort(&mut self, id: &str, effort: String) { + if nebula_core::harness::builtin(id).is_some() + && self.harnesses.get(id).and_then(|o| o.effort_default.clone()).is_none() + { + self.set_legacy_effort(id, effort); + return; + } + self.harness_override_mut(id).effort_default = Some(effort); + } + + /// The `harnesses` map entry for `id`, created when absent. + fn harness_override_mut( + &mut self, + id: &str, + ) -> &mut nebula_core::harness::HarnessOverride { + self.harnesses.entry(id.to_string()).or_default() + } + + fn set_legacy_enabled(&mut self, id: &str, enabled: bool) { + match id { + "claude" => self.claude_enabled = enabled, + "codex" => self.codex_enabled = enabled, + "cursor" => self.cursor_enabled = enabled, + "pi" => self.pi_enabled = enabled, + "muse" => self.muse_enabled = enabled, + _ => {} + } + } + + fn set_legacy_model(&mut self, id: &str, model: String) { + match id { + "claude" => self.claude_model = model, + "codex" => self.codex_model = model, + "cursor" => self.cursor_model = model, + "pi" => self.pi_model = model, + "muse" => self.muse_model = model, + _ => {} + } + } + + fn set_legacy_effort(&mut self, id: &str, effort: String) { + match id { + "claude" => self.claude_effort = effort, + "codex" => self.codex_effort = effort, + "cursor" => self.cursor_effort = effort, + "pi" => self.pi_effort = effort, + "muse" => self.muse_effort = effort, + _ => {} + } + } + /// The AGENT KIND the QUICK PROMPT launches: the `quick_prompt_kind` /// setting, stepped on to the first enabled kind when that harness has /// been switched off on the AGENTS TAB since it was chosen (an @@ -1132,24 +1663,7 @@ impl Config { .recent_prompts_count .clamp(1, nebula_core::RECENT_PROMPTS_KEPT) .to_string(), - SettingKind::ClaudeModel => self.claude_model.clone(), - SettingKind::ClaudeEffort => self.claude_effort.clone(), - SettingKind::CodexModel => self.codex_model.clone(), - SettingKind::CodexEffort => self.codex_effort.clone(), - SettingKind::CursorModel => self.cursor_model.clone(), - SettingKind::CursorEffort => { - if effort_choices(AgentKind::Cursor, Some(&self.cursor_model)).is_empty() { - "n/a".into() - } else { - self.cursor_effort.clone() - } - } - SettingKind::PiModel => self.pi_model.clone(), - SettingKind::PiEffort => self.pi_effort.clone(), - SettingKind::ClaudeEnabled => on_off(self.claude_enabled).into(), - SettingKind::CodexEnabled => on_off(self.codex_enabled).into(), - SettingKind::CursorEnabled => on_off(self.cursor_enabled).into(), - SettingKind::PiEnabled => on_off(self.pi_enabled).into(), + SettingKind::HideUninstalledHarnesses => on_off(self.hide_uninstalled_harnesses).into(), SettingKind::QuickPromptKind => self.quick_prompt_kind.clone(), SettingKind::QuickPromptFocus => on_off(self.quick_prompt_focus).into(), } @@ -1158,12 +1672,28 @@ impl Config { /// `delta == 0` means activate (toggle a bool, cycle a choice forward). /// Non-zero delta cycles a choice; bools still toggle. `index` is /// tab-local — the Hotkeys tab has no cyclable values and no-ops here. + /// The Agents tab resolves its head rows statically and its harness + /// rows through the registry. pub fn cycle(&mut self, tab: usize, index: usize, delta: i32) { + if tab == agents_tab() { + if let Some(spec) = AGENTS_HEAD.get(index) { + self.cycle_kind(spec.kind, delta); + } else if let Some((id, field)) = self.agent_row(index) { + self.cycle_agent_row(&id, field, delta); + } + return; + } let Some(spec) = setting_at(tab, index) else { return; }; + self.cycle_kind(spec.kind, delta); + } + + /// Cycle one static setting row. The Agents tab's harness rows cycle + /// through [`Config::cycle_agent_row`] instead. + fn cycle_kind(&mut self, kind: SettingKind, delta: i32) { let step = if delta == 0 { 1 } else { delta }; - match spec.kind { + match kind { SettingKind::PaletteEnterAttaches => { self.palette_enter_attaches = !self.palette_enter_attaches; } @@ -1240,64 +1770,16 @@ impl Config { SettingKind::ShowKeyCombos => { self.show_key_combos = !self.show_key_combos; } - SettingKind::ClaudeModel => { - self.claude_model = - cycle_choice(&self.claude_model, model_choices(AgentKind::Claude), step).into(); - } - SettingKind::ClaudeEffort => { - self.claude_effort = cycle_choice(&self.claude_effort, CLAUDE_EFFORTS, step).into(); - } - SettingKind::CodexModel => { - self.codex_model = cycle_choice(&self.codex_model, CODEX_MODELS, step).into(); - } - SettingKind::CodexEffort => { - self.codex_effort = cycle_choice(&self.codex_effort, CODEX_EFFORTS, step).into(); - } - SettingKind::ClaudeEnabled => { - self.claude_enabled = !self.claude_enabled; - } - SettingKind::CodexEnabled => { - self.codex_enabled = !self.codex_enabled; - } - SettingKind::CursorEnabled => { - self.cursor_enabled = !self.cursor_enabled; - } - SettingKind::PiEnabled => { - self.pi_enabled = !self.pi_enabled; - } - SettingKind::PiModel => { - self.pi_model = cycle_choice(&self.pi_model, PI_MODELS, step).into(); - } - SettingKind::PiEffort => { - self.pi_effort = cycle_choice(&self.pi_effort, PI_EFFORTS, step).into(); - } - SettingKind::CursorModel => { - self.cursor_model = - cycle_choice(&self.cursor_model, crate::cursor_catalogue::models(), step) - .into(); - // The effort list follows the family: an effort the new - // family lacks becomes its fallback (or default), never an - // id the CLI would refuse. - let choices = effort_choices(AgentKind::Cursor, Some(&self.cursor_model)); - if !fits(&self.cursor_effort, choices) { - self.cursor_effort = - fit_effort(AgentKind::Cursor, Some(&self.cursor_model), None) - .unwrap_or_else(|| DEFAULT_CHOICE.into()); - } + SettingKind::HideUninstalledHarnesses => { + self.hide_uninstalled_harnesses = !self.hide_uninstalled_harnesses; } SettingKind::QuickPromptKind => { self.quick_prompt_kind = - cycle_choice(&self.quick_prompt_kind, AGENT_KIND_NAMES, step).into(); + cycle_owned(&self.quick_prompt_kind, &agent_kind_names(), step); } SettingKind::QuickPromptFocus => { self.quick_prompt_focus = !self.quick_prompt_focus; } - SettingKind::CursorEffort => { - let choices = effort_choices(AgentKind::Cursor, Some(&self.cursor_model)); - if !choices.is_empty() { - self.cursor_effort = cycle_choice(&self.cursor_effort, choices, step).into(); - } - } } } @@ -1395,6 +1877,29 @@ fn resolve_editor(env: Option<&str>, configured: &str) -> String { "vim".into() } +/// Whether `kind`'s CLI resolves on this process's PATH right now. A fast +/// synchronous check for picker filtering only; the daemon re-probes +/// through the login shell at launch, which can see shims PATH misses. +/// Whether `program` resolves on this process's PATH right now — the +/// fast check behind `hide_uninstalled_harnesses`, for built-ins and +/// customs alike. +pub fn program_installed(program: &str) -> bool { + let program = program.trim(); + if program.is_empty() { + return false; + } + let Some(paths) = std::env::var_os("PATH") else { + return false; + }; + for dir in std::env::split_paths(&paths) { + let candidate = dir.join(program); + if candidate.is_file() { + return true; + } + } + false +} + /// [`DEFAULT_CHOICE`] (or blank) → None; anything else passes through. pub(crate) fn non_default(value: &str) -> Option { let value = value.trim(); @@ -1533,6 +2038,27 @@ mod tests { serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap() } + /// Run `f` with the config pinned to an empty temp file, so every + /// registry read (Agents rows, choice lists, tab lengths) sees a + /// fresh install — never the dev's own file. + fn with_empty_config(f: impl FnOnce() -> T) -> T { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.json"); + std::fs::write(&path, "{}").unwrap(); + with_config_path(path, f) + } + + /// Where an Agents harness row lives in `cfg`'s own rows, without + /// loading anything: [`locate_agent`] reads the live file, which a + /// test's in-memory config may have left behind. + fn locate_in(cfg: &Config, id: &str, field: HarnessField) -> Option<(usize, usize)> { + let tab = agents_tab(); + cfg.agent_rows() + .iter() + .position(|(row_id, row_field)| row_id == id && *row_field == field) + .map(|i| (tab, AGENTS_HEAD.len() + i)) + } + /// The first compatibility rule in docs/configuration.md: a key, once /// shipped, keeps its name, its type and its meaning. Every key a /// release wrote must still load to exactly the value it wrote — a @@ -2363,8 +2889,12 @@ mod tests { /// off since it was chosen. #[test] fn quick_prompt_kind_cycles_every_harness_and_persists() { - let names: Vec<&str> = AgentKind::ALL.iter().map(|k| k.as_str()).collect(); - assert_eq!(AGENT_KIND_NAMES, names.as_slice(), "one choice per kind"); + let names: Vec = AgentKind::ALL + .iter() + .filter(|k| **k != AgentKind::Custom) + .map(|k| k.as_str().to_string()) + .collect(); + assert_eq!(agent_kind_names(), names, "one choice per kind"); let mut cfg = Config::default(); assert_eq!(cfg.quick_prompt_kind(), AgentKind::Claude); @@ -2401,17 +2931,35 @@ mod tests { fn harness_toggles_default_on_and_persist() { let mut cfg = Config::default(); assert!(cfg.claude_enabled && cfg.codex_enabled && cfg.cursor_enabled); - assert_eq!(cfg.enabled_kinds(), AgentKind::ALL.to_vec()); + assert!(cfg.pi_enabled && cfg.muse_enabled); + let builtin: Vec = AgentKind::ALL + .into_iter() + .filter(|kind| *kind != AgentKind::Custom) + .collect(); + assert_eq!(cfg.enabled_kinds(), builtin); + assert!( + !cfg.kind_enabled(AgentKind::Custom), + "a bare Custom kind is never enabled: entries gate themselves" + ); - let (tab, row) = locate(SettingKind::CodexEnabled).unwrap(); + let (tab, row) = locate_in(&cfg, "codex", HarnessField::Enabled).unwrap(); cfg.cycle(tab, row, 0); assert!(!cfg.codex_enabled); assert!(!cfg.kind_enabled(AgentKind::Codex)); assert_eq!( cfg.enabled_kinds(), - vec![AgentKind::Claude, AgentKind::Cursor, AgentKind::Pi], + vec![ + AgentKind::Claude, + AgentKind::Cursor, + AgentKind::Pi, + AgentKind::Muse + ], "the disabled kind drops out, order kept" ); + assert!( + !cfg.enabled_kinds().contains(&AgentKind::Custom), + "a bare Custom kind never lists" + ); // ←/→ toggle a bool just like Enter does. cfg.cycle(tab, row, -1); assert!(cfg.codex_enabled); @@ -2425,18 +2973,175 @@ mod tests { assert!(loaded.claude_enabled); assert!(!loaded.codex_enabled); assert!(loaded.cursor_enabled); - // A config predating the keys offers every harness. + // A config predating the keys offers every built-in harness (a + // bare Custom kind never lists — entries come from the registry). let cfg: Config = serde_json::from_str("{}").unwrap(); - assert_eq!(cfg.enabled_kinds().len(), AgentKind::ALL.len()); + assert_eq!(cfg.enabled_kinds().len(), AgentKind::ALL.len() - 1); // Every kind off is representable (a hand edit), and reads as empty. let cfg: Config = serde_json::from_str( - r#"{"claude_enabled":false,"codex_enabled":false,"cursor_enabled":false,"pi_enabled":false}"#, + r#"{"claude_enabled":false,"codex_enabled":false,"cursor_enabled":false,"pi_enabled":false,"muse_enabled":false}"#, ) .unwrap(); assert!(cfg.enabled_kinds().is_empty()); } + #[test] + fn visible_kinds_hides_only_when_asked_and_only_missing_clis() { + // Off by default: the picker lists everything enabled, even when + // no CLI is on PATH (the daemon checks through the login shell). + let cfg = Config::default(); + assert!(!cfg.hide_uninstalled_harnesses); + assert_eq!(cfg.visible_kinds(), cfg.enabled_kinds()); + assert!(cfg.visible_kinds().contains(&AgentKind::Muse)); + + // On: only CLIs found on PATH survive. Point PATH at a dir + // holding just a fake `muse` binary. + let dir = tempfile::tempdir().unwrap(); + let muse_bin = dir.path().join("muse"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::write(&muse_bin, "#!/bin/sh\nexit 0\n").unwrap(); + let mut perms = std::fs::metadata(&muse_bin).unwrap().permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&muse_bin, perms).unwrap(); + } + #[cfg(not(unix))] + std::fs::write(&muse_bin, "").unwrap(); + let prior = std::env::var_os("PATH"); + std::env::set_var("PATH", dir.path()); + let filtered = Config { + hide_uninstalled_harnesses: true, + ..Config::default() + } + .visible_kinds(); + if let Some(prior) = prior { + std::env::set_var("PATH", prior); + } else { + std::env::remove_var("PATH"); + } + assert_eq!(filtered, vec![AgentKind::Muse]); + } + + #[test] + fn offered_harnesses_list_usable_entries_in_registry_order() { + let cfg = Config::default(); + assert_eq!( + cfg.offered_harnesses(), + vec![ + (AgentKind::Claude, None), + (AgentKind::Codex, None), + (AgentKind::Cursor, None), + (AgentKind::Pi, None), + (AgentKind::Muse, None), + ] + ); + + // Disabled and broken entries are out; the tab still lists them + // (with their reason) so they can be fixed. + let cfg: Config = serde_json::from_str( + r#"{"custom_harnesses": [ + {"id": "agy", "program": "agy"}, + {"id": "off", "program": "off", "enabled": false}, + {"id": "broken", "program": ""} + ]}"#, + ) + .unwrap(); + let offered = cfg.offered_harnesses(); + assert_eq!(offered.len(), 6); + assert_eq!(offered[5], (AgentKind::Custom, Some("agy".into()))); + let rows = cfg.agent_rows(); + assert!(rows.contains(&("off".to_string(), HarnessField::Enabled))); + assert!(rows.contains(&("broken".to_string(), HarnessField::Enabled))); + assert!( + cfg.agent_hint("broken", HarnessField::Enabled) + .contains("broken:"), + "the tab names the reason" + ); + + // Under the hide switch an entry survives only when its program + // is on PATH, built-ins and customs alike. + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("agy"), "").unwrap(); + let prior = std::env::var_os("PATH"); + std::env::set_var("PATH", dir.path()); + let hiding = Config { + hide_uninstalled_harnesses: true, + ..serde_json::from_str::( + r#"{"custom_harnesses": [ + {"id": "agy", "program": "agy"}, + {"id": "missing", "program": "definitely-not-on-path"} + ]}"#, + ) + .unwrap() + }; + let offered = hiding.offered_harnesses(); + if let Some(prior) = prior { + std::env::set_var("PATH", prior); + } else { + std::env::remove_var("PATH"); + } + assert_eq!(offered.len(), 1); + assert_eq!(offered[0], (AgentKind::Custom, Some("agy".into()))); + } + + /// Safety: one broken `harnesses` entry never takes the rest down. + /// The picker hides it, the tab shows its reason, and every other + /// harness launches exactly as before. + #[test] + fn a_broken_harness_entry_isolates_itself() { + let cfg: Config = serde_json::from_str( + r#"{"harnesses": { + "codex": {"program": ""}, + "agy": {"program": "agy", "resume_flag": "--resume"} + }}"#, + ) + .unwrap(); + let offered = cfg.offered_harnesses(); + assert!( + !offered.iter().any(|(kind, _)| *kind == AgentKind::Codex), + "the broken built-in hides" + ); + assert!( + offered.contains(&(AgentKind::Custom, Some("agy".into()))), + "the valid newcomer offers" + ); + assert_eq!(cfg.default_model(AgentKind::Claude), None); + assert_eq!(cfg.default_model(AgentKind::Codex), None); + assert!(cfg.agent_hint("codex", HarnessField::Enabled).contains("broken:")); + // The entry still resolves for reads (placeholder-free), while + // launches refuse it with the reason. + let codex = cfg.effective_harness_by_id("codex"); + assert!(codex.problem().is_some()); + assert_eq!( + nebula_core::harness::resolve(&cfg.harness_registry(), AgentKind::Codex, None) + .unwrap_err(), + "harness `codex` has no program" + ); + } + + /// Safety: a `harnesses` map that fails to parse costs only its own + /// key — every other setting keeps its value, and the registry reads + /// as a fresh install. + #[test] + fn an_unreadable_harnesses_map_costs_only_that_key() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.json"); + std::fs::write( + &path, + r#"{"harnesses": {"claude": {"enabled": "yes"}}, "theme": "ocean"}"#, + ) + .unwrap(); + let cfg = load_from(&path); + assert_eq!(cfg.theme, "ocean"); + assert_eq!( + cfg.skipped, + BTreeSet::from(["harnesses".to_string()]) + ); + assert!(cfg.harness_registry().iter().all(|entry| entry.enabled)); + } + #[test] fn model_effort_defaults_resolve_and_cycle() { let mut cfg = Config::default(); @@ -2465,12 +3170,22 @@ mod tests { cfg.pi_effort = "xhigh".into(); assert_eq!(cfg.default_model(AgentKind::Pi).as_deref(), Some("sonnet")); assert_eq!(cfg.default_effort(AgentKind::Pi).as_deref(), Some("xhigh")); - let (tab, row) = locate(SettingKind::PiEffort).unwrap(); + // Muse passes --model through verbatim; effort is reserved and + // never sent, whatever the file holds. + assert_eq!(cfg.default_model(AgentKind::Muse), None); + assert_eq!(cfg.default_effort(AgentKind::Muse), None); + cfg.muse_model = "spark".into(); + cfg.muse_effort = "high".into(); + assert_eq!(cfg.default_model(AgentKind::Muse).as_deref(), Some("spark")); + assert_eq!(cfg.default_effort(AgentKind::Muse), None); + assert_eq!(AgentKind::parse("muse"), Some(AgentKind::Muse)); + assert_eq!(AgentKind::Muse.cli_program(), "muse"); + let (tab, row) = locate_in(&cfg, "pi", HarnessField::Effort).unwrap(); cfg.cycle(tab, row, 1); - assert_eq!(cfg.value_label(SettingKind::PiEffort), "max"); + assert_eq!(cfg.agent_value("pi", HarnessField::Effort), "max"); cfg.cycle(tab, row, 1); assert_eq!( - cfg.value_label(SettingKind::PiEffort), + cfg.agent_value("pi", HarnessField::Effort), DEFAULT_CHOICE, "wraps" ); @@ -2512,13 +3227,13 @@ mod tests { ); // The settings rows walk the same choice lists the submenus show. - let (tab, row) = locate(SettingKind::ClaudeModel).unwrap(); + let (tab, row) = locate_in(&cfg, "claude", HarnessField::Model).unwrap(); cfg.claude_model = "default".into(); cfg.cycle(tab, row, 1); assert_eq!(cfg.claude_model, "fable"); cfg.cycle(tab, row, -1); assert_eq!(cfg.claude_model, "default"); - let (tab, row) = locate(SettingKind::CodexEffort).unwrap(); + let (tab, row) = locate_in(&cfg, "codex", HarnessField::Effort).unwrap(); cfg.cycle(tab, row, 0); assert_eq!( cfg.codex_effort, "xhigh", @@ -2529,17 +3244,17 @@ mod tests { #[test] fn cursor_settings_rows_follow_the_family() { let mut cfg = Config::default(); - let (tab, model_row) = locate(SettingKind::CursorModel).unwrap(); - let (_, effort_row) = locate(SettingKind::CursorEffort).unwrap(); + let (tab, model_row) = locate_in(&cfg, "cursor", HarnessField::Model).unwrap(); + let (_, effort_row) = locate_in(&cfg, "cursor", HarnessField::Effort).unwrap(); // No family: the effort row is n/a and does not cycle. - assert_eq!(cfg.value_label(SettingKind::CursorEffort), "n/a"); + assert_eq!(cfg.agent_value("cursor", HarnessField::Effort), "n/a"); cfg.cycle(tab, effort_row, 1); assert_eq!(cfg.cursor_effort, "default"); // default → auto (still no efforts) → claude-fable-5, which has no // bare id, so the effort lands on its fallback at once. cfg.cycle(tab, model_row, 1); assert_eq!(cfg.cursor_model, "auto"); - assert_eq!(cfg.value_label(SettingKind::CursorEffort), "n/a"); + assert_eq!(cfg.agent_value("cursor", HarnessField::Effort), "n/a"); cfg.cycle(tab, model_row, 1); assert_eq!(cfg.cursor_model, "claude-fable-5"); assert_eq!(cfg.cursor_effort, "high"); @@ -2566,8 +3281,9 @@ mod tests { #[test] fn fit_effort_resolves_cursor_pairs() { + let cursor = nebula_core::harness::builtin("cursor").unwrap(); let fit = |m: Option<&str>, e: Option<&str>| { - fit_effort(AgentKind::Cursor, m, e.map(String::from)) + fit_effort_in(&cursor, m, e.map(String::from)) }; assert_eq!(fit(None, Some("high")), None, "no family, nothing to join"); assert_eq!(fit(Some("default"), Some("high")), None); @@ -2601,8 +3317,9 @@ mod tests { fit(Some("gpt-5.5"), Some("extra-high-fast")).as_deref(), Some("extra-high-fast") ); + let codex = nebula_core::harness::builtin("codex").unwrap(); assert_eq!( - fit_effort(AgentKind::Codex, None, Some("high".into())).as_deref(), + fit_effort_in(&codex, None, Some("high".into())).as_deref(), Some("high"), "claude/codex pass through" ); @@ -2692,106 +3409,200 @@ mod tests { #[test] fn tabs_cover_every_setting_once_and_rows_match() { - // Every SettingKind appears exactly once across the tabs. - let mut kinds: Vec = all_settings().map(|(_, _, s)| s.kind).collect(); - let total = kinds.len(); - kinds.sort_by_key(|k| format!("{k:?}")); - kinds.dedup(); - assert_eq!(kinds.len(), total, "a kind repeats across tabs"); - - // Each tab's rows walk its own index space, in order. - for (t, tab) in SETTINGS_TABS.iter().enumerate() { - let indices: Vec = settings_rows(t) - .into_iter() - .filter_map(|row| row.index()) - .collect(); - assert_eq!( - indices, - (0..tab_len(t)).collect::>(), - "{} rows", - tab.title - ); - } + with_empty_config(|| { + // Every SettingKind appears exactly once across the tabs. + let mut kinds: Vec = all_settings().map(|(_, _, s)| s.kind).collect(); + let total = kinds.len(); + kinds.sort_by_key(|k| format!("{k:?}")); + kinds.dedup(); + assert_eq!(kinds.len(), total, "a kind repeats across tabs"); + + // Each tab's rows walk its own index space, in order. + for (t, tab) in SETTINGS_TABS.iter().enumerate() { + let indices: Vec = settings_rows(t) + .into_iter() + .filter_map(|row| row.index()) + .collect(); + assert_eq!( + indices, + (0..tab_len(t)).collect::>(), + "{} rows", + tab.title + ); + } - // A value tab carries headers exactly when its rows name groups; - // Hotkeys always does. - for (t, tab) in SETTINGS_TABS.iter().enumerate() { - let headers = settings_rows(t) - .into_iter() - .filter(|row| matches!(row, SettingsRow::Header(_))) - .count(); - match tab.body { - TabBody::Values(settings) => { - let grouped = settings.iter().any(|s| !s.group.is_empty()); - assert_eq!(headers > 0, grouped, "{}", tab.title); + // A value tab carries headers exactly when its rows name + // groups; Hotkeys and Agents always do. + for (t, tab) in SETTINGS_TABS.iter().enumerate() { + let headers = settings_rows(t) + .into_iter() + .filter(|row| matches!(row, SettingsRow::Header(_))) + .count(); + match tab.body { + TabBody::Values(settings) => { + let grouped = settings.iter().any(|s| !s.group.is_empty()); + assert_eq!(headers > 0, grouped, "{}", tab.title); + } + TabBody::Hotkeys => assert!(headers > 0, "hotkeys tab groups its rows"), + TabBody::Agents => assert!(headers > 0, "agents tab groups its rows"), } - TabBody::Hotkeys => assert!(headers > 0, "hotkeys tab groups its rows"), } - } + }); } #[test] fn agents_tab_groups_its_rows_per_harness() { - let (tab, _) = locate(SettingKind::ClaudeEnabled).unwrap(); - assert_eq!(SETTINGS_TABS[tab].title, "Agents"); - - // Read the rows back the way the screen shows them: a header, - // then the labels under it, with a blank between sections. - let mut sections: Vec<(&str, Vec<&str>)> = Vec::new(); - for row in settings_rows(tab) { - match row { - SettingsRow::Header(title) => sections.push((title, Vec::new())), - SettingsRow::Setting(i) => sections - .last_mut() - .expect("every Agents row sits under a header") - .1 - .push(setting_at(tab, i).unwrap().label), - SettingsRow::Blank => assert!(!sections.is_empty(), "no leading blank"), - SettingsRow::Hotkey(_) => unreachable!(), + with_empty_config(|| { + let tab = agents_tab(); + assert_eq!(SETTINGS_TABS[tab].title, "Agents"); + let cfg = Config::load(); + + // Read the rows back the way the screen shows them: a header, + // then the labels under it, with a blank between sections. + let mut sections: Vec<(String, Vec)> = Vec::new(); + for row in settings_rows(tab) { + match row { + SettingsRow::Header(title) => sections.push((title, Vec::new())), + SettingsRow::Setting(i) => { + let label = match AGENTS_HEAD.get(i) { + Some(spec) => spec.label.to_string(), + None => { + cfg.agent_row(i) + .map(|(_, field)| field.label().to_string()) + .expect("every Agents row resolves") + } + }; + sections + .last_mut() + .expect("every Agents row sits under a header") + .1 + .push(label); + } + SettingsRow::Blank => assert!(!sections.is_empty(), "no leading blank"), + SettingsRow::Hotkey(_) => unreachable!(), + } } - } - assert_eq!( - sections, - vec![ - ("Quick prompt", vec!["Agent", "Focus"]), - ("Claude", vec!["Enabled", "Model", "Effort"]), - ("Codex", vec!["Enabled", "Model", "Effort"]), - ("Cursor", vec!["Enabled", "Model", "Effort"]), - ("Pi", vec!["Enabled", "Model", "Effort"]), - ] - ); - - // The header a row sits under names the kind whose setting it is, - // so the shortened labels can never drift onto the wrong harness. - for (_, _, spec) in all_settings().filter(|(t, _, _)| *t == tab) { - let kind = format!("{:?}", spec.kind); - let harness = match spec.group { - "Quick prompt" => "QuickPrompt", - other => other, - }; - assert!( - kind.starts_with(harness), - "{kind} sits under {}", - spec.group + assert_eq!( + sections, + vec![ + ( + "Quick prompt".to_string(), + vec![ + "Agent".to_string(), + "Focus".to_string(), + "Hide missing CLIs".to_string() + ] + ), + ( + "Claude".to_string(), + vec!["Enabled".to_string(), "Model".to_string(), "Effort".to_string()] + ), + ( + "Codex".to_string(), + vec!["Enabled".to_string(), "Model".to_string(), "Effort".to_string()] + ), + ( + "Cursor".to_string(), + vec!["Enabled".to_string(), "Model".to_string(), "Effort".to_string()] + ), + ( + "Pi".to_string(), + vec!["Enabled".to_string(), "Model".to_string(), "Effort".to_string()] + ), + ( + "Muse".to_string(), + vec!["Enabled".to_string(), "Model".to_string(), "Effort".to_string()] + ), + ] ); - } - // One blank line separates the sections and nothing else does. - let blanks = settings_rows(tab) - .into_iter() - .filter(|row| *row == SettingsRow::Blank) - .count(); - assert_eq!(blanks, sections.len() - 1); + // One blank line separates the sections and nothing else does. + let blanks = settings_rows(tab) + .into_iter() + .filter(|row| *row == SettingsRow::Blank) + .count(); + assert_eq!(blanks, sections.len() - 1); + }); + } + + /// A new CLI in the registry grows its own Agents section — model and + /// effort rows included — with no code change, and the picker offers + /// it in the same order. + #[test] + fn agents_tab_grows_a_section_per_registry_entry() { + with_empty_config(|| { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.json"); + std::fs::write( + &path, + r#"{"harnesses": { + "agy": { + "program": "agy", + "model_default": "big-1", + "effort_flag": "--effort", + "efforts": ["low", "high"], + "resume_flag": "--resume", + "hooks": "claude" + } + }}"#, + ) + .unwrap(); + with_config_path(path, || { + let tab = agents_tab(); + let cfg = Config::load(); + let sections: Vec = settings_rows(tab) + .into_iter() + .filter_map(|row| match row { + SettingsRow::Header(title) => Some(title), + _ => None, + }) + .collect(); + assert_eq!( + sections, + vec!["Quick prompt", "Claude", "Codex", "Cursor", "Pi", "Muse", "agy"] + ); + let (_, model_row) = + locate_agent("agy", HarnessField::Model).expect("the newcomer locates"); + assert_eq!(cfg.agent_value("agy", HarnessField::Model), "big-1"); + let (_, effort_row) = + locate_agent("agy", HarnessField::Effort).expect("its effort row shows"); + assert_eq!(cfg.agent_value("agy", HarnessField::Effort), "default"); + assert_eq!(tab_len(tab), AGENTS_HEAD.len() + cfg.agent_rows().len()); + // ... and the picker offers it after the built-ins. + let offered = cfg.offered_harnesses(); + assert_eq!( + offered.last(), + Some(&(AgentKind::Custom, Some("agy".to_string()))) + ); + // Cycling its rows edits the map entry a save persists: + // an off-list hand edit steps onto the list, effort + // steps forward through its rows. + let mut cfg = cfg; + cfg.cycle(tab, model_row, 0); + assert_eq!( + cfg.harnesses["agy"].model_default.as_deref(), + Some("default"), + "an off-list hand edit steps onto the offered rows" + ); + cfg.cycle(tab, effort_row, 1); + assert_eq!( + cfg.harnesses["agy"].effort_default.as_deref(), + Some("low") + ); + }); + }); } #[test] fn every_tab_holds_something() { - assert!(tab_count() >= 2); - for (t, tab) in SETTINGS_TABS.iter().enumerate() { - assert!(tab_len(t) > 0, "{} is empty", tab.title); - assert!(!tab.title.is_empty()); - } - assert_eq!(tab_len(hotkeys_tab()), crate::keymap::ACTIONS.len()); + with_empty_config(|| { + assert!(tab_count() >= 2); + for (t, tab) in SETTINGS_TABS.iter().enumerate() { + assert!(tab_len(t) > 0, "{} is empty", tab.title); + assert!(!tab.title.is_empty()); + } + assert_eq!(tab_len(hotkeys_tab()), crate::keymap::ACTIONS.len()); + }); } #[test] diff --git a/crates/nebula-tui/src/event_loop.rs b/crates/nebula-tui/src/event_loop.rs index 7ba9a65..6fefd14 100644 --- a/crates/nebula-tui/src/event_loop.rs +++ b/crates/nebula-tui/src/event_loop.rs @@ -3625,6 +3625,7 @@ fn build_submenu(item: &MenuItem) -> Option { let MenuAction::NewAgentOfKind { worktree, kind, + custom, model, cloud, pr, @@ -3640,19 +3641,22 @@ fn build_submenu(item: &MenuItem) -> Option { // already set to launch with. let from_box = quick .as_ref() - .filter(|q| q.launch.kind == *kind) + .filter(|q| q.launch.kind == *kind && q.launch.custom.as_deref() == custom.as_deref()) .map(|q| &q.launch); let (title, choices, configured) = match sub { SubmenuKind::Models => ( - format!("{} model", kind_label(*kind)), - crate::config::model_choices(*kind), + format!( + "{} model", + crate::agent_picker::harness_label(*kind, custom.as_deref()) + ), + crate::config::model_choices(*kind, custom.as_deref()), from_box .and_then(|l| l.model.clone()) .or_else(|| cfg.default_model(*kind)), ), SubmenuKind::Efforts => ( format!("{} effort", kind_label(*kind)), - crate::config::effort_choices(*kind, model.as_deref()), + crate::config::effort_choices(*kind, model.as_deref(), custom.as_deref()), from_box .and_then(|l| l.effort.clone()) .or_else(|| cfg.default_effort(*kind)), @@ -3671,6 +3675,7 @@ fn build_submenu(item: &MenuItem) -> Option { MenuAction::NewAgentOfKind { worktree: worktree.clone(), kind: *kind, + custom: custom.clone(), model: match sub { SubmenuKind::Models => Some((*choice).to_string()), SubmenuKind::Efforts => model.clone(), @@ -4106,6 +4111,23 @@ pub(crate) fn handle_overlay_key(app: &mut App, key: KeyEvent, out: &mut Vec crate::issues::handle_key(app, key), Overlay::BranchSwitch(_) => crate::branch_switch::handle_key(app, key), Overlay::Menu(menu) => match key.code { + // `?` (and `s` where no filter eats letters) on a row that + // starts a session jumps to that harness's Agents section, + // where its defaults live. Ahead of type-ahead on purpose: + // `?` is a jump on these rows, never a filter letter (no row + // contains one); `s` only jumps without a filter, since in + // the model/effort submenus it narrows the list. `s` keeps + // its global meaning — settings — so the picker agrees with + // the panels. + KeyCode::Char(c) + if (c == '?' || (c == 's' && menu.filter.is_none())) + && menu.hovered_agent_kind().is_some() => + { + let (kind, custom) = menu + .hovered_agent_kind() + .expect("the guard checked the hovered row"); + open_harness_settings(app, kind, custom); + } // Type-ahead in the MODEL / EFFORT submenus: letters narrow the // rows (so ↑/↓ move there, not j/k), Backspace widens, and Esc // clears the text before it backs out. A letter no row matches @@ -4895,9 +4917,10 @@ fn apply_setting_at(app: &mut App, tab: usize, index: usize, delta: i32) { } let mut cfg = crate::config::Config::load(); cfg.cycle(tab, index, delta); - if cfg.enabled_kinds().is_empty() { + if nebula_core::harness::usable(&cfg.harness_registry()).is_empty() { // Refuse the last harness here, where the user is looking, rather - // than leave `n` with nothing to offer later. + // than leave `n` with nothing to offer later. Custom registry + // entries count: with one offered, the built-ins may all go off. if let Some(view) = settings_mut(app) { view.warn("keep at least one harness enabled"); } @@ -4960,6 +4983,30 @@ fn open_settings(app: &mut App) { reopen_settings(app); } +/// Swap a session picker for the settings overlay parked on that +/// harness's Agents section, cursor on its Enabled row. The remembered +/// tab and row update too, so an Esc-then-`s` lands back where `?` left. +fn open_harness_settings(app: &mut App, kind: AgentKind, custom: Option) { + use crate::config::{agents_tab, locate_agent, HarnessField}; + let id = match kind { + AgentKind::Custom => custom.unwrap_or_default(), + _ => kind.as_str().to_string(), + }; + let tab = agents_tab(); + app.settings_tab = tab; + if let Some((_, row)) = locate_agent(&id, HarnessField::Enabled) { + if let Some(slot) = app.settings_selected.get_mut(tab) { + *slot = row; + } + } + app.settings_on_tabs = false; + app.overlay = Some(Overlay::Settings(SettingsView::new( + tab, + app.settings_row(tab), + false, + ))); +} + /// Put the settings overlay back up on its remembered tab and row, no /// questions asked. `open_settings` is the from-the-panels entry that /// checks the memory's age first; this one is for mid-visit round trips @@ -5261,6 +5308,7 @@ fn submit_prompt(app: &mut App, prompt: PromptDialog, out: &mut Vec { + let descriptor = cfg.effective_harness(kind, custom.as_deref()); + let model = preset.model.clone().or_else(|| { + descriptor.default_model().map(str::to_string) + }); + let effort = crate::config::fit_effort( + kind, + model.as_deref(), + preset + .effort + .clone() + .or_else(|| descriptor.default_effort().map(str::to_string)), + custom.as_deref(), + ); + (model, effort) + } + _ => { + let model = preset.model.clone().or_else(|| cfg.default_model(kind)); + let effort = crate::config::fit_effort( + kind, + model.as_deref(), + preset.effort.clone().or_else(|| cfg.default_effort(kind)), + None, + ); + (model, effort) + } + }; create_agent( app, AgentLaunchDraft { worktree: worktree.clone(), kind, + custom, model, effort, name: String::new(), @@ -5574,6 +5648,7 @@ fn run_menu_action(app: &mut App, action: MenuAction, out: &mut Vec configured, some => some, }; - let model = resolve(model, cfg.default_model(kind)); + let harness = cfg.effective_harness(kind, custom.as_deref()); + let model = resolve(model, harness.default_model().map(str::to_string)); // A Cursor effort only counts for the family it belongs to: a // stale setting behind a freshly picked model drops to none. let effort = crate::config::fit_effort( kind, model.as_deref(), - resolve(effort, cfg.default_effort(kind)), + resolve(effort, harness.default_effort().map(str::to_string)), + custom.as_deref(), ); // A Claude Cloud launch has its own task box, and nothing to // name first. @@ -5642,6 +5720,7 @@ fn run_menu_action(app: &mut App, action: MenuAction, out: &mut Vec open_new_worktree_prompt(app, project), MenuAction::OpenLink(url) => open_link(app, &url, out), @@ -6730,6 +6818,8 @@ fn fire_pending_prewarm(app: &mut App, out: &mut Vec) { struct AgentLaunchDraft { worktree: WorktreeId, kind: AgentKind, + /// Registry id when `kind` is [`AgentKind::Custom`]. + custom: Option, model: Option, effort: Option, name: String, @@ -6775,6 +6865,7 @@ fn create_agent(app: &mut App, draft: AgentLaunchDraft, out: &mut Vec = menu.items.iter().map(|item| item.label.as_str()).collect(); assert_eq!( labels, - ["Claude", "Cursor", "Pi"], + ["Claude", "Cursor", "Pi", "Muse"], "Codex is absent, not greyed" ); @@ -15452,7 +15693,7 @@ diff --git a/src/c.rs b/src/c.rs #[test] fn picker_with_every_harness_disabled_flashes_instead_of_opening() { with_config_json( - r#"{"claude_enabled": false, "codex_enabled": false, "cursor_enabled": false, "pi_enabled": false}"#, + r#"{"claude_enabled": false, "codex_enabled": false, "cursor_enabled": false, "pi_enabled": false, "muse_enabled": false}"#, || { let mut app = App::new(); seed_tree(&mut app); @@ -15493,7 +15734,11 @@ diff --git a/src/c.rs b/src/c.rs !labels.contains(&"Claude") && labels.contains(&"Codex"), "Claude is absent, not greyed: {labels:?}" ); - assert_eq!(labels.len(), AgentKind::ALL.len() - 1); + assert_eq!( + labels.len(), + AgentKind::ALL.len() - 2, + "minus Codex, minus the bare Custom kind, which never lists" + ); assert!(app.flash.is_none(), "got {:?}", app.flash); app.overlay = None; @@ -15543,7 +15788,7 @@ diff --git a/src/c.rs b/src/c.rs #[test] fn every_harness_disabled_flashes_instead_of_a_pr_session_picker() { with_config_json( - r#"{"claude_enabled": false, "codex_enabled": false, "cursor_enabled": false, "pi_enabled": false}"#, + r#"{"claude_enabled": false, "codex_enabled": false, "cursor_enabled": false, "pi_enabled": false, "muse_enabled": false}"#, || { let mut app = App::new(); seed_tree(&mut app); @@ -15602,14 +15847,15 @@ diff --git a/src/c.rs b/src/c.rs #[test] fn agents_tab_toggles_a_harness_and_refuses_the_last_one() { - use crate::config::{locate, SettingKind}; + use crate::config::{locate_agent, HarnessField}; let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("config.json"); crate::config::with_config_path(path.clone(), || { - let (tab, claude_row) = locate(SettingKind::ClaudeEnabled).unwrap(); - let (_, codex_row) = locate(SettingKind::CodexEnabled).unwrap(); - let (_, cursor_row) = locate(SettingKind::CursorEnabled).unwrap(); - let (_, pi_row) = locate(SettingKind::PiEnabled).unwrap(); + let (tab, claude_row) = locate_agent("claude", HarnessField::Enabled).unwrap(); + let (_, codex_row) = locate_agent("codex", HarnessField::Enabled).unwrap(); + let (_, cursor_row) = locate_agent("cursor", HarnessField::Enabled).unwrap(); + let (_, pi_row) = locate_agent("pi", HarnessField::Enabled).unwrap(); + let (_, muse_row) = locate_agent("muse", HarnessField::Enabled).unwrap(); let mut app = App::new(); let mut out = Vec::new(); open_settings_on(&mut app, tab, &mut out); @@ -15638,9 +15884,15 @@ diff --git a/src/c.rs b/src/c.rs press(&mut app, KeyCode::Down, KeyModifiers::NONE, &mut out); } press(&mut app, KeyCode::Enter, KeyModifiers::NONE, &mut out); + assert!(!crate::config::Config::load().pi_enabled); + + for _ in pi_row..muse_row { + press(&mut app, KeyCode::Down, KeyModifiers::NONE, &mut out); + } + press(&mut app, KeyCode::Enter, KeyModifiers::NONE, &mut out); let cfg = crate::config::Config::load(); - assert!(cfg.pi_enabled, "the last harness cannot be switched off"); - assert_eq!(cfg.enabled_kinds(), vec![AgentKind::Pi]); + assert!(cfg.muse_enabled, "the last harness cannot be switched off"); + assert_eq!(cfg.enabled_kinds(), vec![AgentKind::Muse]); let (text, level) = settings_view(&app).notice.clone().expect("a warning"); assert!(matches!(level, crate::app::NoticeLevel::Warn)); assert!(text.contains("at least one harness"), "{text}"); @@ -16520,6 +16772,7 @@ diff --git a/src/c.rs b/src/c.rs archived_at: 0, unseen: false, kind: nebula_core::AgentKind::Claude, + custom_harness: None, model: None, effort: None, session_id: None, @@ -16607,6 +16860,7 @@ diff --git a/src/c.rs b/src/c.rs archived_at: 0, unseen: false, kind: nebula_core::AgentKind::Claude, + custom_harness: None, model: None, effort: None, session_id: None, @@ -16964,6 +17218,7 @@ diff --git a/src/c.rs b/src/c.rs unseen: false, archived_at, kind: nebula_core::AgentKind::Claude, + custom_harness: None, model: None, effort: None, session_id: None, @@ -18312,6 +18567,7 @@ diff --git a/src/c.rs b/src/c.rs archived_at: 0, unseen: false, kind: nebula_core::AgentKind::Claude, + custom_harness: None, model: None, effort: None, session_id: None, @@ -18607,6 +18863,7 @@ diff --git a/src/c.rs b/src/c.rs archived_at: 0, unseen: false, kind: nebula_core::AgentKind::Claude, + custom_harness: None, model: None, effort: None, session_id: None, @@ -19218,6 +19475,7 @@ diff --git a/src/c.rs b/src/c.rs archived_at: 0, unseen: false, kind: nebula_core::AgentKind::Claude, + custom_harness: None, model: None, effort: None, session_id: None, @@ -20345,6 +20603,7 @@ diff --git a/src/c.rs b/src/c.rs archived_at: 0, unseen: false, kind: nebula_core::AgentKind::Codex, + custom_harness: None, model: None, effort: None, session_id: None, @@ -20368,6 +20627,7 @@ diff --git a/src/c.rs b/src/c.rs archived_at: 0, unseen: false, kind: nebula_core::AgentKind::Claude, + custom_harness: None, model: None, effort: None, session_id: None, @@ -20598,6 +20858,7 @@ diff --git a/src/c.rs b/src/c.rs archived_at: 0, unseen, kind: nebula_core::AgentKind::Claude, + custom_harness: None, model: None, effort: None, session_id: None, @@ -21062,6 +21323,7 @@ diff --git a/src/c.rs b/src/c.rs archived_at: 0, unseen: false, kind: nebula_core::AgentKind::Claude, + custom_harness: None, model: None, effort: None, session_id: None, @@ -21481,9 +21743,10 @@ diff --git a/src/c.rs b/src/c.rs #[test] fn agents_tab_renders_its_harness_groups() { + use crate::config::{HarnessField, locate_agent}; let mut app = App::new(); let mut out = Vec::new(); - let (agents, _) = crate::config::locate(crate::config::SettingKind::ClaudeEnabled).unwrap(); + let (agents, _) = locate_agent("claude", HarnessField::Enabled).unwrap(); open_settings_on(&mut app, agents, &mut out); // Tall enough for every row, so nothing scrolls off. let mut terminal = Terminal::new(TestBackend::new(100, 40)).unwrap(); @@ -21497,6 +21760,7 @@ diff --git a/src/c.rs b/src/c.rs "Quick prompt", "Agent", "Focus", + "Hide missing CLIs", "Claude", "Enabled", "Model", @@ -21520,12 +21784,12 @@ diff --git a/src/c.rs b/src/c.rs "the old flat labels are gone:\n{text}" ); - // Headers and blanks are not rows the cursor can land on: two ↓ - // from the first row reach the third setting, not a header. + // Headers and blanks are not rows the cursor can land on: three ↓ + // from the first row reach Claude's Enabled row, not a header. + press(&mut app, KeyCode::Char('j'), KeyModifiers::NONE, &mut out); press(&mut app, KeyCode::Char('j'), KeyModifiers::NONE, &mut out); press(&mut app, KeyCode::Char('j'), KeyModifiers::NONE, &mut out); - let (_, claude_enabled) = - crate::config::locate(crate::config::SettingKind::ClaudeEnabled).unwrap(); + let (_, claude_enabled) = locate_agent("claude", HarnessField::Enabled).unwrap(); assert_eq!(settings_view(&app).selected, claude_enabled); // Nor can a click land on one: the "Codex" header is a dead cell. @@ -23176,6 +23440,7 @@ diff --git a/src/c.rs b/src/c.rs archived_at: 0, unseen: false, kind: nebula_core::AgentKind::Claude, + custom_harness: None, model: None, effort: None, session_id: None, @@ -23247,6 +23512,7 @@ diff --git a/src/c.rs b/src/c.rs archived_at: 0, unseen: false, kind: nebula_core::AgentKind::Claude, + custom_harness: None, model: None, effort: None, session_id: None, @@ -23892,6 +24158,7 @@ diff --git a/src/c.rs b/src/c.rs archived_at: 0, unseen: false, kind: AgentKind::Claude, + custom_harness: None, model: None, effort: None, session_id: None, @@ -24088,6 +24355,7 @@ diff --git a/src/c.rs b/src/c.rs archived_at: 0, unseen: false, kind: nebula_core::AgentKind::Claude, + custom_harness: None, model: None, effort: None, session_id: None, @@ -24182,6 +24450,7 @@ diff --git a/src/c.rs b/src/c.rs archived_at: 0, unseen: false, kind: nebula_core::AgentKind::Claude, + custom_harness: None, model: None, effort: None, session_id: None, @@ -24212,6 +24481,7 @@ diff --git a/src/c.rs b/src/c.rs archived_at: 0, unseen: true, kind: nebula_core::AgentKind::Claude, + custom_harness: None, model: None, effort: None, session_id: None, @@ -24288,6 +24558,7 @@ diff --git a/src/c.rs b/src/c.rs archived_at: 0, unseen: false, kind: AgentKind::Claude, + custom_harness: None, model: None, effort: None, session_id: None, @@ -25717,6 +25988,7 @@ diff --git a/src/c.rs b/src/c.rs AgentPreset { name: "reviewer".into(), kind: AgentKind::Claude, + custom_harness: None, model: Some("opus".into()), effort: Some("high".into()), prefix: "Be strict.".into(), @@ -25726,6 +25998,7 @@ diff --git a/src/c.rs b/src/c.rs AgentPreset { name: "scratch".into(), kind: AgentKind::Codex, + custom_harness: None, model: Some("gpt-5.5".into()), effort: None, prefix: String::new(), @@ -26040,6 +26313,7 @@ diff --git a/src/c.rs b/src/c.rs [ClientRequest::CreateAgent { worktree: w, kind: AgentKind::Claude, + custom_harness: None, model: Some(model), effort: Some(effort), auto_title: true, @@ -26122,6 +26396,7 @@ diff --git a/src/c.rs b/src/c.rs [ClientRequest::CreateAgent { worktree: w, kind: AgentKind::Codex, + custom_harness: None, starting_prompt: None, .. }] if *w == worktree @@ -26139,6 +26414,7 @@ diff --git a/src/c.rs b/src/c.rs out.as_slice(), [ClientRequest::CreateAgent { kind: AgentKind::Claude, + custom_harness: None, starting_prompt: Some(text), .. }] if text == "Be strict.\n\nRun the tests." @@ -26198,6 +26474,7 @@ diff --git a/src/c.rs b/src/c.rs [ClientRequest::CreateAgent { worktree: w, kind: AgentKind::Claude, + custom_harness: None, model: Some(model), starting_prompt: Some(text), .. @@ -26272,6 +26549,7 @@ diff --git a/src/c.rs b/src/c.rs [ClientRequest::CreateAgent { worktree: w, kind: AgentKind::Codex, + custom_harness: None, model: Some(model), effort: Some(effort), auto_title: true, @@ -26500,6 +26778,7 @@ diff --git a/src/c.rs b/src/c.rs [ClientRequest::CreateAgent { worktree, kind: AgentKind::Codex, + custom_harness: None, auto_title: true, cloud_prompt: None, starting_prompt: Some(text), @@ -26623,6 +26902,7 @@ diff --git a/src/c.rs b/src/c.rs archived_at: 0, unseen: false, kind: nebula_core::AgentKind::Codex, + custom_harness: None, model: None, effort: None, session_id: None, @@ -26725,6 +27005,7 @@ diff --git a/src/c.rs b/src/c.rs [ClientRequest::CreateAgent { worktree: w, kind: AgentKind::Codex, + custom_harness: None, model: Some(m), starting_prompt: Some(text), .. @@ -26816,6 +27097,7 @@ diff --git a/src/c.rs b/src/c.rs out.as_slice(), [ClientRequest::CreateAgent { kind: AgentKind::Claude, + custom_harness: None, model: Some(model), effort: Some(effort), starting_prompt: Some(text), @@ -26844,6 +27126,7 @@ diff --git a/src/c.rs b/src/c.rs out, [ClientRequest::CreateAgent { kind: AgentKind::Claude, + custom_harness: None, starting_prompt: Some(text), .. }] if text == "Be strict.\n\nRun the tests." @@ -27589,7 +27872,7 @@ diff --git a/src/c.rs b/src/c.rs assert_eq!(menu.title.as_deref(), Some("Cursor model")); assert_eq!( menu.items.len(), - crate::config::model_choices(AgentKind::Cursor).len() + crate::config::model_choices(AgentKind::Cursor, None).len() ); assert_eq!(menu.items[0].label, "default ✓"); assert_eq!(menu.items[1].label, "auto"); diff --git a/crates/nebula-tui/src/event_loop/placeholder.rs b/crates/nebula-tui/src/event_loop/placeholder.rs index 4d2282d..a38b50f 100644 --- a/crates/nebula-tui/src/event_loop/placeholder.rs +++ b/crates/nebula-tui/src/event_loop/placeholder.rs @@ -81,6 +81,7 @@ pub(super) fn stage( project: ProjectId, branch: String, kind: AgentKind, + custom: Option, model: Option, effort: Option, out: &mut Vec, @@ -102,6 +103,7 @@ pub(super) fn stage( archived_at: 0, unseen: false, kind, + custom_harness: custom, model, effort, session_id: None, @@ -434,6 +436,7 @@ mod tests { archived_at: 0, unseen: false, kind: AgentKind::Claude, + custom_harness: None, model: None, effort: None, session_id: None, diff --git a/crates/nebula-tui/src/event_loop/quick_launch.rs b/crates/nebula-tui/src/event_loop/quick_launch.rs index 931b892..7037546 100644 --- a/crates/nebula-tui/src/event_loop/quick_launch.rs +++ b/crates/nebula-tui/src/event_loop/quick_launch.rs @@ -35,6 +35,7 @@ pub(super) fn submit( project.clone(), branch.clone(), launch.kind, + launch.custom.clone(), launch.model.clone(), launch.effort.clone(), out, @@ -114,6 +115,7 @@ fn draft( AgentLaunchDraft { worktree, kind: launch.kind, + custom: launch.custom.clone(), model: launch.model.clone(), effort: launch.effort.clone(), name: String::new(), diff --git a/crates/nebula-tui/src/palette.rs b/crates/nebula-tui/src/palette.rs index c26e606..43bc057 100644 --- a/crates/nebula-tui/src/palette.rs +++ b/crates/nebula-tui/src/palette.rs @@ -430,6 +430,7 @@ mod tests { archived_at: 0, unseen, kind: AgentKind::Claude, + custom_harness: None, model: None, effort: None, session_id: None, diff --git a/crates/nebula-tui/src/preset_overlays.rs b/crates/nebula-tui/src/preset_overlays.rs index 33f1142..f2bb045 100644 --- a/crates/nebula-tui/src/preset_overlays.rs +++ b/crates/nebula-tui/src/preset_overlays.rs @@ -111,26 +111,35 @@ impl PresetField { ]; /// The field `delta` steps away in Tab order, wrapping, and skipping - /// an Effort the (kind, model) pair has no choice for — a Cursor + /// an Effort the (harness, model) pair has no choice for — a Cursor /// family without effort variants, or no Cursor model yet. - pub fn step(self, kind: AgentKind, model: &str, delta: i32) -> PresetField { + pub fn step( + self, + kind: AgentKind, + custom: Option<&str>, + model: &str, + delta: i32, + ) -> PresetField { let n = Self::ALL.len() as i32; let mut pos = Self::ALL.iter().position(|f| *f == self).unwrap_or(0) as i32; for _ in 0..n { pos = (pos + delta).rem_euclid(n); let next = Self::ALL[pos as usize]; - if next.available(kind, model) { + if next.available(kind, custom, model) { return next; } } self } - /// Whether the field applies to `kind` (with `model` chosen) at all. - pub fn available(self, kind: AgentKind, model: &str) -> bool { + /// Whether the field applies to the harness (with `model` chosen) at + /// all. + pub fn available(self, kind: AgentKind, custom: Option<&str>, model: &str) -> bool { match self { - PresetField::Model => !crate::config::model_choices(kind).is_empty(), - PresetField::Effort => !crate::config::effort_choices(kind, Some(model)).is_empty(), + PresetField::Model => !crate::config::model_choices(kind, custom).is_empty(), + PresetField::Effort => { + !crate::config::effort_choices(kind, Some(model), custom).is_empty() + } _ => true, } } @@ -169,6 +178,8 @@ pub struct AgentPresetEditor { pub editing: Option, pub name: TextInput, pub kind: AgentKind, + /// Registry id when `kind` is [`AgentKind::Custom`]. + pub custom: Option, pub model: String, pub effort: String, pub prefix: TextInput, @@ -195,6 +206,7 @@ impl AgentPresetEditor { editing: None, name: TextInput::new(), kind: AgentKind::Claude, + custom: None, model: crate::config::DEFAULT_CHOICE.into(), effort: crate::config::DEFAULT_CHOICE.into(), prefix: TextInput::new(), @@ -217,6 +229,7 @@ impl AgentPresetEditor { editing: Some(index), name: TextInput::with_text(preset.name.clone()), kind: preset.kind, + custom: preset.custom_harness.clone(), model: choice(&preset.model), effort: choice(&preset.effort), prefix: TextInput::with_text(preset.prefix.clone()), @@ -243,25 +256,36 @@ impl AgentPresetEditor { /// Switch harness, dropping a model / effort the new kind doesn't list /// back to the default so the form never holds a choice it can't show. - pub fn set_kind(&mut self, kind: AgentKind) { + pub fn set_kind(&mut self, kind: AgentKind, custom: Option) { self.kind = kind; - if !fits(&self.model, crate::config::model_choices(kind)) { + self.custom = custom; + if !fits( + &self.model, + &crate::config::model_choices(kind, self.custom.as_deref()), + ) { self.model = crate::config::DEFAULT_CHOICE.into(); } self.fit_effort(); - if !self.field.available(kind, &self.model) { + if !self.field.available(kind, self.custom.as_deref(), &self.model) { self.field = PresetField::Prefix; } } - /// Drop an effort the current (kind, model) pair doesn't list — Cursor's - /// list follows the family — to what "default" would launch: the - /// family's fallback for a Cursor family with no bare id, else default. + /// Drop an effort the current (harness, model) pair doesn't list — a + /// composing harness's list follows the family — to what "default" + /// would launch: the family's fallback for a family with no bare id, + /// else default. fn fit_effort(&mut self) { - let choices = crate::config::effort_choices(self.kind, Some(&self.model)); - if !fits(&self.effort, choices) { - self.effort = crate::config::fit_effort(self.kind, Some(&self.model), None) - .unwrap_or_else(|| crate::config::DEFAULT_CHOICE.into()); + let choices = + crate::config::effort_choices(self.kind, Some(&self.model), self.custom.as_deref()); + if !fits(&self.effort, &choices) { + self.effort = crate::config::fit_effort( + self.kind, + Some(&self.model), + None, + self.custom.as_deref(), + ) + .unwrap_or_else(|| crate::config::DEFAULT_CHOICE.into()); } } @@ -270,18 +294,24 @@ impl AgentPresetEditor { /// a text row and on an `n/a` row. fn row_choices(&self) -> Vec { match self.field { - PresetField::Kind => AgentKind::ALL - .iter() - .map(|k| k.as_str().to_string()) - .collect(), - PresetField::Model => crate::config::model_choices(self.kind) - .iter() - .map(|s| s.to_string()) - .collect(), - PresetField::Effort => crate::config::effort_choices(self.kind, Some(&self.model)) - .iter() - .map(|s| s.to_string()) + // Every offered harness by id — built-ins and customs alike, + // in registry order. Never a bare `custom`, which carries no + // entry. + PresetField::Kind => crate::config::Config::load() + .offered_harnesses() + .into_iter() + .map(|(kind, custom)| { + custom.unwrap_or_else(|| kind.as_str().to_string()) + }) .collect(), + PresetField::Model => { + crate::config::model_choices(self.kind, self.custom.as_deref()) + } + PresetField::Effort => crate::config::effort_choices( + self.kind, + Some(&self.model), + self.custom.as_deref(), + ), PresetField::Task => vec![TASK_ASK.to_string(), TASK_SKIP.to_string()], _ => Vec::new(), } @@ -289,7 +319,11 @@ impl AgentPresetEditor { fn row_value(&self) -> String { match self.field { - PresetField::Kind => self.kind.as_str().to_string(), + PresetField::Kind => self + .custom + .as_deref() + .unwrap_or_else(|| self.kind.as_str()) + .to_string(), PresetField::Model => self.model.clone(), PresetField::Effort => self.effort.clone(), PresetField::Task => self.task_choice().to_string(), @@ -301,7 +335,13 @@ impl AgentPresetEditor { match self.field { PresetField::Kind => { if let Some(kind) = AgentKind::parse(value) { - self.set_kind(kind); + self.set_kind(kind, None); + } else if crate::config::Config::load() + .harness_registry() + .iter() + .any(|entry| entry.id == value) + { + self.set_kind(AgentKind::Custom, Some(value.to_string())); } } PresetField::Model => { @@ -395,6 +435,7 @@ impl AgentPresetEditor { AgentPreset { name: self.name.trim().to_string(), kind: self.kind, + custom_harness: self.custom.clone(), model: crate::config::non_default(&self.model), effort: crate::config::non_default(&self.effort), prefix: self.prefix.as_str().to_string(), @@ -527,10 +568,10 @@ pub(crate) fn open_agent_preset_task( app.flash = Some("no preset selected — a creates one".into()); return; }; - if !crate::config::Config::load().kind_enabled(preset.kind) { + if !crate::config::Config::load().preset_harness_usable(&preset) { app.flash = Some(format!( "{} is turned off in Settings → Agents", - preset.kind.as_str() + preset.custom_harness.as_deref().unwrap_or(preset.kind.as_str()) )); return; } @@ -565,10 +606,10 @@ fn apply_preset_to_quick_prompt( return; }; let cfg = crate::config::Config::load(); - if !cfg.kind_enabled(preset.kind) { + if !cfg.preset_harness_usable(&preset) { app.flash = Some(format!( "{} is turned off in Settings → Agents", - preset.kind.as_str() + preset.custom_harness.as_deref().unwrap_or(preset.kind.as_str()) )); return; } @@ -656,11 +697,21 @@ pub(crate) fn handle_editor_key(app: &mut App, key: KeyEvent) { // Leaving a choice row drops its type-ahead. KeyCode::Tab | KeyCode::Down => { editor.filter.clear(); - editor.field = editor.field.step(editor.kind, &editor.model, 1) + editor.field = editor.field.step( + editor.kind, + editor.custom.as_deref(), + &editor.model, + 1, + ) } KeyCode::BackTab | KeyCode::Up => { editor.filter.clear(); - editor.field = editor.field.step(editor.kind, &editor.model, -1) + editor.field = editor.field.step( + editor.kind, + editor.custom.as_deref(), + &editor.model, + -1, + ) } // A hard line in the prefix / postfix, as in the task editor. KeyCode::Char('j') if multiline && ctrl => editor.prefix_or_postfix_newline(), @@ -841,7 +892,8 @@ pub(crate) fn draw_editor(f: &mut Frame, app: &mut App, editor: &AgentPresetEdit break; }; let focused = editor.field == *field; - let available = field.available(editor.kind, &editor.model); + let available = + field.available(editor.kind, editor.custom.as_deref(), &editor.model); let label_style = if focused { Style::default().fg(th.accent).add_modifier(Modifier::BOLD) } else if available { @@ -866,7 +918,11 @@ pub(crate) fn draw_editor(f: &mut Frame, app: &mut App, editor: &AgentPresetEdit } _ => { let value = match field { - PresetField::Kind => editor.kind.as_str().to_string(), + PresetField::Kind => editor + .custom + .as_deref() + .unwrap_or_else(|| editor.kind.as_str()) + .to_string(), PresetField::Model => editor.model.clone(), PresetField::Effort => editor.effort.clone(), PresetField::Task => editor.task_choice().to_string(), @@ -970,3 +1026,44 @@ pub(crate) fn draw_editor(f: &mut Frame, app: &mut App, editor: &AgentPresetEdit e.area = area; } } + +#[cfg(test)] +mod tests { + use super::*; + use nebula_core::WorktreeId; + + fn pinned(json: &str, f: impl FnOnce() -> T) -> T { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.json"); + std::fs::write(&path, json).unwrap(); + crate::config::with_config_path(path, f) + } + + /// The Harness row lists custom entries by id after the built-ins — + /// never a bare `custom` — and picking one stores the registry id. + #[test] + fn kind_choices_list_custom_entries_by_id() { + pinned( + r#"{"custom_harnesses": [{"id": "agy", "program": "agy"}]}"#, + || { + let mut editor = AgentPresetEditor::new(WorktreeId("w1".into())); + editor.field = PresetField::Kind; + let choices = editor.row_choices(); + assert!(choices.contains(&"claude".to_string()), "{choices:?}"); + assert!(choices.contains(&"agy".to_string()), "{choices:?}"); + assert!(!choices.contains(&"custom".to_string()), "{choices:?}"); + + editor.set_row_value("agy"); + assert_eq!(editor.kind, AgentKind::Custom); + assert_eq!(editor.custom.as_deref(), Some("agy")); + assert_eq!(editor.row_value(), "agy"); + let preset = editor.to_preset(); + assert_eq!(preset.custom_harness.as_deref(), Some("agy")); + + editor.set_row_value("codex"); + assert_eq!(editor.kind, AgentKind::Codex); + assert_eq!(editor.custom, None); + }, + ); + } +} diff --git a/crates/nebula-tui/src/quick_prompt.rs b/crates/nebula-tui/src/quick_prompt.rs index d112505..7936e56 100644 --- a/crates/nebula-tui/src/quick_prompt.rs +++ b/crates/nebula-tui/src/quick_prompt.rs @@ -61,6 +61,8 @@ pub enum QuickOrigin { pub struct QuickLaunch { pub target: QuickTarget, pub kind: AgentKind, + /// Registry id when `kind` is [`AgentKind::Custom`]. + pub custom: Option, pub model: Option, pub effort: Option, /// The AGENT PRESET `Shift+Tab` picked: its prefix and postfix wrap @@ -98,6 +100,7 @@ impl QuickLaunch { Self::of_kind( target, kind, + None, cfg.default_model(kind), cfg.default_effort(kind), cfg, @@ -110,19 +113,25 @@ impl QuickLaunch { pub fn of_kind( target: QuickTarget, kind: AgentKind, + custom: Option, model: Option, effort: Option, cfg: &Config, ) -> Self { - let model = model.or_else(|| cfg.default_model(kind)); + // Defaults resolve from the registry descriptor: its own model + // default, and its effort default fitted to the model. + let descriptor = cfg.effective_harness(kind, custom.as_deref()); + let model = model.or_else(|| descriptor.default_model().map(str::to_string)); let effort = fit_effort( kind, model.as_deref(), - effort.or_else(|| cfg.default_effort(kind)), + effort.or_else(|| descriptor.default_effort().map(str::to_string)), + custom.as_deref(), ); Self { target, kind, + custom, model, effort, preset: None, @@ -172,6 +181,7 @@ impl QuickLaunch { let mut launch = Self::of_kind( target, preset.kind, + preset.custom_harness.clone(), preset.model.clone(), preset.effort.clone(), cfg, @@ -198,7 +208,11 @@ impl QuickLaunch { /// `Quick prompt · issue #15 · reviewer (claude · opus)` — and, for /// the NEW SESSION PICKER's box, `New session (claude · opus · high)`. pub fn title(&self) -> String { - let opts: Vec<&str> = std::iter::once(self.kind.as_str()) + let harness = self + .custom + .as_deref() + .unwrap_or_else(|| self.kind.as_str()); + let opts: Vec<&str> = std::iter::once(harness) .chain(self.model.as_deref()) .chain(self.effort.as_deref()) .collect(); @@ -314,12 +328,14 @@ pub(crate) fn open_for_new_session( app: &mut App, worktree: WorktreeId, kind: AgentKind, + custom: Option, model: Option, effort: Option, cfg: &Config, ) { - let launch = QuickLaunch::of_kind(QuickTarget::Worktree(worktree), kind, model, effort, cfg) - .with_origin(QuickOrigin::NewSession); + let launch = + QuickLaunch::of_kind(QuickTarget::Worktree(worktree), kind, custom, model, effort, cfg) + .with_origin(QuickOrigin::NewSession); crate::event_loop::open_prompt(app, PromptKind::QuickPrompt(launch)); } @@ -479,6 +495,7 @@ mod tests { AgentPreset { name: name.into(), kind, + custom_harness: None, model: None, effort: None, prefix: String::new(), @@ -536,12 +553,13 @@ mod tests { codex_effort: "high".into(), ..Config::default() }; - let launch = QuickLaunch::of_kind(worktree(), AgentKind::Codex, None, None, &cfg); + let launch = QuickLaunch::of_kind(worktree(), AgentKind::Codex, None, None, None, &cfg); assert_eq!(launch.model.as_deref(), Some("gpt-5.5")); assert_eq!(launch.effort.as_deref(), Some("high")); let launch = QuickLaunch::of_kind( worktree(), AgentKind::Codex, + None, Some("gpt-5.1-codex".into()), Some("low".into()), &cfg, @@ -584,6 +602,7 @@ mod tests { let plain = QuickLaunch::of_kind( worktree(), AgentKind::Claude, + None, Some("opus".into()), Some("high".into()), &cfg, @@ -623,7 +642,7 @@ mod tests { number: 15, title: "Fix login redirect".into(), }; - let plain = QuickLaunch::of_kind(worktree(), AgentKind::Claude, None, None, &cfg) + let plain = QuickLaunch::of_kind(worktree(), AgentKind::Claude, None, None, None, &cfg) .with_issue(Some(issue.clone())); assert_eq!(plain.title(), "Quick prompt · issue #15 (claude)"); assert_eq!( @@ -649,7 +668,7 @@ mod tests { "reviewer — sent as the first prompt (empty = fix the issue)" ); assert!(wrapped.default_task().is_some()); - let none = QuickLaunch::of_kind(worktree(), AgentKind::Claude, None, None, &cfg); + let none = QuickLaunch::of_kind(worktree(), AgentKind::Claude, None, None, None, &cfg); assert_eq!(none.default_task(), None, "an empty ordinary box cancels"); } @@ -661,6 +680,7 @@ mod tests { let fresh = QuickLaunch::of_kind( new_worktree("yellow-fox-jumps"), AgentKind::Claude, + None, Some("opus".into()), None, &cfg, @@ -688,7 +708,7 @@ mod tests { #[test] fn the_new_session_box_is_titled_for_the_picker_and_launches_empty() { let cfg = Config::default(); - let hotkey = QuickLaunch::of_kind(worktree(), AgentKind::Claude, None, None, &cfg); + let hotkey = QuickLaunch::of_kind(worktree(), AgentKind::Claude, None, None, None, &cfg); assert_eq!(hotkey.origin, QuickOrigin::Hotkey); assert_eq!(hotkey.title(), "Quick prompt (claude)"); assert_eq!(hotkey.label(), "what should the agent do?"); @@ -701,6 +721,7 @@ mod tests { let picked = QuickLaunch::of_kind( worktree(), AgentKind::Claude, + None, Some("opus".into()), Some("high".into()), &cfg, diff --git a/crates/nebula-tui/src/ui.rs b/crates/nebula-tui/src/ui.rs index 90f8f37..6afd034 100644 --- a/crates/nebula-tui/src/ui.rs +++ b/crates/nebula-tui/src/ui.rs @@ -337,19 +337,26 @@ fn draw_overlay(f: &mut Frame, app: &mut App) { // extra column so the affordance is visible before hovering. let any_submenu = menu.items.iter().any(|i| i.action.submenu().is_some()); // The workspace switcher carries its key verbs in the bottom - // border; the modal widens to fit. + // border; the modal widens to fit. Session pickers add the + // `?` jump to the hovered harness's Agents section. + let agent_jump = menu.hovered_agent_kind().is_some(); let hint = if menu.is_workspace_picker() { Some(" n: new r: rename d: delete ") } else if menu.filter.is_some() { - Some(" type to filter ↑↓: move Backspace Esc: back ") + Some(if agent_jump { + " type to filter ?: settings ↑↓: move Backspace Esc: back " + } else { + " type to filter ↑↓: move Backspace Esc: back " + }) } else { - menu.hovered_claude_cloud().map(|cloud| { - if cloud { - " Tab: cloud on " + match menu.hovered_claude_cloud() { + Some(cloud) => Some(if cloud { + " Tab: cloud on s/?: settings " } else { - " Tab: cloud off " - } - }) + " Tab: cloud off s/?: settings " + }), + None => agent_jump.then_some(" s/?: settings "), + } }; let width = (label_w + 4 + if any_submenu { 2 } else { 0 }) .max(title_width + 2) @@ -887,9 +894,30 @@ fn draw_overlay(f: &mut Frame, app: &mut App) { ))); } crate::config::SettingsRow::Setting(i) => { - let spec = crate::config::setting_at(tab, *i) - .expect("settings_rows indexes this tab's settings"); - let value = cfg.value_label(spec.kind); + // The Agents tab resolves its harness rows through + // the registry; every other values tab reads its + // static spec. + let (label, value) = if tab == crate::config::agents_tab() { + match crate::config::AGENTS_HEAD.get(*i) { + Some(spec) => ( + spec.label.to_string(), + cfg.value_label(spec.kind), + ), + None => { + let (id, field) = cfg.agent_row(*i).expect( + "settings_rows indexes the Agents tab's harness rows", + ); + ( + field.label().to_string(), + cfg.agent_value(&id, field), + ) + } + } + } else { + let spec = crate::config::setting_at(tab, *i) + .expect("settings_rows indexes this tab's settings"); + (spec.label.to_string(), cfg.value_label(spec.kind)) + }; let selected = *i == view.selected && !view.on_tabs; let mut label_style = Style::default(); let mut value_style = Style::default().fg(th.accent); @@ -898,7 +926,7 @@ fn draw_overlay(f: &mut Frame, app: &mut App) { value_style = value_style.bg(th.sel_bg).add_modifier(Modifier::BOLD); } lines.push(Line::from(vec![ - Span::styled(format!(" {:<28}", spec.label), label_style), + Span::styled(format!(" {:<28}", label), label_style), Span::styled(format!("[{value}]"), value_style), ])); } @@ -3772,7 +3800,10 @@ fn draw_session_row( // opens the session's page rather than a local CLI. (" cloud".to_string(), Style::default().fg(th.dim)) } else { - (format!(" {}", a.kind.as_str()), Style::default().fg(th.dim)) + ( + format!(" {}", crate::agent_picker::session_harness_badge(a)), + Style::default().fg(th.dim), + ) }; // How long since this session last did anything, sat between // the name and the harness. The list is sorted on this stamp, @@ -5328,6 +5359,7 @@ mod tests { "wt".to_string(), )), kind: nebula_core::AgentKind::Claude, + custom: None, model: None, effort: None, preset: None, @@ -5856,6 +5888,7 @@ mod tests { unseen: false, status_changed_at: 0, kind: nebula_core::AgentKind::Claude, + custom_harness: None, model: None, effort: None, session_id: None, diff --git a/crates/nebula/src/cli.rs b/crates/nebula/src/cli.rs index 205d15f..4cd8a9f 100644 --- a/crates/nebula/src/cli.rs +++ b/crates/nebula/src/cli.rs @@ -61,13 +61,16 @@ Examples: Run `nebula --help` for a command's flags and examples."; -/// `--kind` for `nebula spawn`: one of the agent CLIs nebula runs. +/// `--kind` for `nebula spawn`: one of the agent CLIs nebula runs. A bare +/// `custom` is never accepted: custom harnesses carry a registry id the +/// flag cannot name, so they launch from the TUI picker and presets. fn parse_agent_kind(s: &str) -> Result { nebula_core::AgentKind::parse(s).ok_or_else(|| { format!( - "unknown harness `{s}` — expected one of {}", + "unknown harness `{s}` — expected one of {} (custom harnesses launch from the TUI)", nebula_core::AgentKind::ALL .iter() + .filter(|k| **k != nebula_core::AgentKind::Custom) .map(|k| k.as_str()) .collect::>() .join(", ") @@ -442,6 +445,13 @@ pub(crate) enum ConfigCommand { #[arg(value_name = "SOURCE")] source: String, }, + /// Print the effective harness registry: every harness nebula knows — + /// the built-ins, `custom_harnesses` entries and `harnesses` map ids — + /// with the program, flags, resume shape, hook dialect and defaults a + /// launch actually uses. Copy a row into config.json `harnesses` to + /// override it. + #[command(after_help = "Example:\n nebula config harnesses")] + Harnesses, } #[derive(Subcommand)] diff --git a/crates/nebula/src/main.rs b/crates/nebula/src/main.rs index e7ecd72..fea6f00 100644 --- a/crates/nebula/src/main.rs +++ b/crates/nebula/src/main.rs @@ -41,6 +41,7 @@ fn main() -> Result<()> { ConfigCommand::Path => nebula_tui::ConfigOp::Path, ConfigCommand::Export { path } => nebula_tui::ConfigOp::Export { path }, ConfigCommand::Import { source } => nebula_tui::ConfigOp::Import { source }, + ConfigCommand::Harnesses => nebula_tui::ConfigOp::Harnesses, }), Some(Command::Kill) => nebula_tui::run_kill(), Some(Command::Rename { title, force }) => { diff --git a/crates/nebula/tests/e2e_pty.rs b/crates/nebula/tests/e2e_pty.rs index 7a1dfcc..657865c 100644 --- a/crates/nebula/tests/e2e_pty.rs +++ b/crates/nebula/tests/e2e_pty.rs @@ -377,6 +377,7 @@ async fn full_crud_attach_and_restart_persistence() { worktree: main_worktree.id.clone(), name: "agent-1".into(), kind: AgentKind::Claude, + custom_harness: None, model: None, effort: None, auto_title: false, @@ -809,6 +810,7 @@ async fn hook_post_from_agent_pty_drives_status() { worktree: worktree.id.clone(), name: "hooked".into(), kind: AgentKind::Claude, + custom_harness: None, model: None, effort: None, auto_title: false, @@ -1147,6 +1149,7 @@ async fn hook_cwd_rehomes_agent_to_other_worktree() { worktree: main_worktree.id.clone(), name: "mover".into(), kind: AgentKind::Claude, + custom_harness: None, model: None, effort: None, auto_title: false, @@ -1274,6 +1277,7 @@ async fn claude_session_title_and_row_name_stay_tied() { worktree: worktree.id.clone(), name: "Typed In Nebula".into(), kind: AgentKind::Claude, + custom_harness: None, model: None, effort: None, auto_title: false, @@ -1483,6 +1487,7 @@ async fn move_agent_respawns_live_session_in_target_worktree() { worktree: main_worktree.id.clone(), name: "mover".into(), kind: AgentKind::Claude, + custom_harness: None, model: None, effort: None, auto_title: false, @@ -1625,6 +1630,7 @@ async fn restart_rebinds_an_attached_client_to_the_new_pty() { worktree: main_worktree.id.clone(), name: "agent-1".into(), kind: AgentKind::Claude, + custom_harness: None, model: None, effort: None, auto_title: false, @@ -1760,6 +1766,7 @@ async fn codex_hooks_install_and_drive_status() { worktree: worktree.id.clone(), name: "codexed".into(), kind: AgentKind::Codex, + custom_harness: None, model: None, effort: None, auto_title: false, @@ -2338,6 +2345,7 @@ async fn prewarmed_session_is_adopted_by_create_agent() { worktree: worktree.id.clone(), name: "warm-agent".into(), kind: AgentKind::Claude, + custom_harness: None, model: None, effort: None, auto_title: false, @@ -2485,6 +2493,7 @@ async fn dead_prewarm_falls_back_to_cold_spawn() { worktree: worktree.id.clone(), name: "fallback-agent".into(), kind: AgentKind::Claude, + custom_harness: None, model: None, effort: None, auto_title: false, @@ -2542,6 +2551,7 @@ async fn create_agent_refuses_when_the_cli_is_not_installed() { worktree: worktree.id.clone(), name: format!("agent-{req_id}"), kind, + custom_harness: None, model: None, effort: None, auto_title: false, @@ -2628,6 +2638,7 @@ async fn create_agent_succeeds_when_the_cli_is_on_the_login_shell_path() { worktree: worktree.id.clone(), name: "real-agent".into(), kind: AgentKind::Claude, + custom_harness: None, model: None, effort: None, auto_title: false, @@ -2699,6 +2710,7 @@ async fn create_agent_get_id( worktree: worktree.clone(), name: name.into(), kind: AgentKind::Claude, + custom_harness: None, model: None, effort: None, auto_title: false, @@ -2867,6 +2879,7 @@ async fn archive_sigkills_an_agent_that_ignores_sighup() { worktree: worktree.id.clone(), name: "stubborn".into(), kind: AgentKind::Claude, + custom_harness: None, model: None, effort: None, auto_title: false, @@ -3006,6 +3019,7 @@ async fn prewarm_worktree_sessions_boots_dead_sessions() { worktree: worktree.id.clone(), name: "warmed".into(), kind: AgentKind::Claude, + custom_harness: None, model: None, effort: None, auto_title: false, @@ -3053,6 +3067,7 @@ async fn prewarm_worktree_sessions_boots_dead_sessions() { worktree: worktree.id.clone(), name: "shelved".into(), kind: AgentKind::Claude, + custom_harness: None, model: None, effort: None, auto_title: false, @@ -3168,6 +3183,7 @@ async fn idle_sessions_reap_unwatched_but_spare_busy_and_attached() { worktree: worktree.id.clone(), name: "idler".into(), kind: AgentKind::Claude, + custom_harness: None, model: None, effort: None, auto_title: false, @@ -3411,6 +3427,7 @@ async fn auto_title_instruction_and_rename_flow() { worktree: worktree.id.clone(), name: "agent-1".into(), kind: AgentKind::Claude, + custom_harness: None, model: None, effort: None, auto_title: true, @@ -3613,6 +3630,7 @@ async fn nebula_worktree_cli_relocates_the_session_when_the_turn_ends() { worktree: main_worktree.id.clone(), name: "agent-1".into(), kind: AgentKind::Claude, + custom_harness: None, model: None, effort: None, auto_title: false, @@ -4028,6 +4046,7 @@ async fn nebula_spawn_cli_starts_a_sibling_session_in_the_same_worktree() { worktree: main_worktree.id.clone(), name: "agent-1".into(), kind: AgentKind::Claude, + custom_harness: None, model: None, effort: None, auto_title: false, @@ -4328,6 +4347,7 @@ exit 0 worktree: main_worktree.id.clone(), name: "cloud".into(), kind: AgentKind::Claude, + custom_harness: None, model: None, effort: None, auto_title: false, diff --git a/docs/commands.md b/docs/commands.md index b177f1b..84c0eb9 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -59,7 +59,8 @@ nebula worktree [name] [--base ] # move the current session into a worktre # worktree; no name invents one; --base picks a new branch's start point, # a branch name meaning origin's fetched copy — main is origin/main; # without it the worktree_base_branch setting, else origin's default) -nebula spawn [--kind ] # start a new agent session beside the current +nebula spawn [--kind ] # start a new agent session beside the current + # (custom harnesses launch from the TUI picker and presets, not --kind) # one, in the same worktree, opening on (agents run this when you # ask for a new nebula session; --kind defaults to this session's harness) nebula open … # show the files in this nebula's FILE TABS — a modal with one tab per @@ -89,6 +90,9 @@ nebula config import # merge a backup in: an export, a bare config.js # agent_presets.json / ssh_hosts.json, a folder holding any of # them, or - for stdin. Keys it sets replace this machine's, keys # it lacks stay, and config.local.json is never written +nebula config harnesses # print the effective harness registry: every harness with + # the program, flags, resume shape, hook dialect and defaults a + # launch uses. Copy a row into config.json `harnesses` to override it ``` See [Configuration](configuration.md#backup-restore-and-other-machines). diff --git a/docs/configuration.md b/docs/configuration.md index 757ced9..b6e94eb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -41,11 +41,11 @@ convenience stores: missing or malformed reads as empty. ## Every setting -Forty keys. **Overlay** is the SETTINGS OVERLAY tab whose row edits the key; `—` means the key +Forty-five keys. **Overlay** is the SETTINGS OVERLAY tab whose row edits the key; `—` means the key exists only in the file, so it is hand-edit-only. Most rows toggle or cycle on `Enter` / `←` / `→`; a *typed* row (`worktree_base_branch`) opens a one-line prompt on `Enter` instead, pre-filled with the stored value, and an empty answer puts its default back. The Agents tab groups its rows under **Quick -prompt**, **Claude**, **Codex** and **Cursor** headers, so a harness's rows read `Enabled` / `Model` / +prompt**, **Claude**, **Codex**, **Cursor**, **Pi** and **Muse** headers, so a harness's rows read `Enabled` / `Model` / `Effort` under its name rather than repeating it. The **Experimental** tab holds behaviors that change how the tree is worked; every switch there is off by default. @@ -73,12 +73,14 @@ how the tree is worked; every switch there is off by default. | `recent_prompts` | bool | `false` | Experimental | RECENT PROMPTS: list the last few prompts typed into each session under its row in the SESSIONS PANEL — the text the `UserPromptSubmit` hook carried, condensed to one line — oldest first so the bottom line is the latest ask, each with a dim `30m ago` pinned right; a click on any line lands on its session. Every harness reports its prompt (Claude, Codex and Cursor in the hook payload, Pi through its managed extension). Prompts nebula composes itself — a PR SESSION's scope, the note a `nebula worktree` relocation reopens on — are left out, and archived rows list none. Off, the rows are the single pills they always were. See [Sessions](sessions.md#recent-prompts). | | `recent_prompts_count` | integer | `3` | Experimental | How many of those prompts to list while `recent_prompts` is on. The overlay cycles `1` to `5`; a hand edit is clamped to the ten the DAEMON keeps per session (`0` reads as `1`, `50` as `10`). | | `show_key_combos` | bool | `false` | Experimental | The KEY COMBO DISPLAY: each key pressed in the panels spelled at the bottom left of the screen — the blank row above the FOOTER — with what it did, `j - Move down`, `^d - Half page down`, `h h - Workspaces` for a double tap, so anyone watching a screen share picks the shortcuts up as they are used; vim's `showcmd`. An unbound key shows bare, so a watcher sees it did nothing. Each press replaces the last and clears itself three seconds on. What never shows: keys typed into a LOCKED PANE (they are the agent's — a password at a prompt in there stays off the screen; only the unlock hatch shows, `^q - Unlock terminal input`), and text typed into an overlay's field — inside a modal only `Esc`, `Enter`, `Tab`, the arrows and `^`/`⌥` chords show, bare. See [Keys](keys.md#chips-and-readouts). | -| `quick_prompt_kind` | string | `"claude"` | Agents | Which AGENT KIND the QUICK PROMPT (`p`) launches: `claude`, `codex`, `cursor` or `pi`. Its model and effort come from that kind's own defaults below, so this is one name, not a third pair. A kind switched off here is stepped around. | +| `quick_prompt_kind` | string | `"claude"` | Agents | Which AGENT KIND the QUICK PROMPT (`p`) launches: `claude`, `codex`, `cursor`, `pi` or `muse`. Its model and effort come from that kind's own defaults below, so this is one name, not a third pair. A kind switched off here is stepped around. | | `quick_prompt_focus` | bool | `false` | Agents | QUICK PROMPT FOCUS: whether a QUICK PROMPT launch enters and locks the new session's TERMINAL PANE. Off, its row is selected and previewed but FOCUS stays on the panel you fired from. Only the QUICK PROMPT reads it — every other launch takes the pane. | | `claude_enabled` | bool | `true` | Agents | HARNESS TOGGLE. Off leaves Claude out of the NEW SESSION PICKER and the PR SESSION picker, and skips the standing PREWARM POOL slot; existing sessions keep attaching and resuming. The last kind left on cannot be switched off. | | `codex_enabled` | bool | `true` | Agents | HARNESS TOGGLE for Codex, same rules. | | `cursor_enabled` | bool | `true` | Agents | HARNESS TOGGLE for Cursor, same rules. | | `pi_enabled` | bool | `true` | Agents | HARNESS TOGGLE for Pi, same rules. | +| `muse_enabled` | bool | `true` | Agents | HARNESS TOGGLE for Muse, same rules. Muse has no managed hooks yet, so its status stays process-based (running while the PTY is live, no waiting-on-you detection). | +| `hide_uninstalled_harnesses` | bool | `false` | Agents | When on, the NEW SESSION PICKER lists only enabled harnesses whose CLI is found on PATH. Off by default: a login shell can see CLIs a plain PATH lookup misses, and the daemon re-checks through the login shell at launch anyway. | | `claude_model` | string | `"default"` | Agents | Default `--model` for new Claude sessions. The literal `"default"` is the sentinel meaning *don't pass the flag, let the CLI pick* — it is what you see in a fresh file, not a missing value. Overlay list: `fable`, `opus`, `sonnet`, `haiku` — unless `claude_models` below or Claude Code's own `availableModels` allowlist replaces it; any other string is passed through verbatim. | | `claude_models` | array of strings | `[]` | — (hand-edited) | The Claude model rows every picker offers (the NEW SESSION PICKER and QUICK PROMPT submenus, the AGENTS TAB, the PRESET EDITOR) in place of the built-in aliases, verbatim, `"default"` always first: `["claude-sonnet-5", "us.anthropic.claude-opus-5-v1:0"]`. For an organization that restricts models (Claude Code refuses `--model sonnet` with *Model "sonnet" is restricted by your organization's settings. Using claude-sonnet-5 instead.*) or a provider whose ids the aliases don't reach (Bedrock, Vertex, a gateway; on Bedrock `sonnet` even means Sonnet 4.5). Empty, the list follows Claude Code's `availableModels` when one is on disk — `~/.claude/remote-settings.json` (server-managed cache), the macOS MDM profile, `managed-settings.json` and `managed-settings.d/` in the system directory, then `~/.claude/settings.json`, read once at TUI start — else the aliases. A hand edit here applies without a restart. | | `claude_effort` | string | `"default"` | Agents | Default reasoning effort (`--effort`) for new Claude sessions: `low`, `medium`, `high`, `xhigh`, `max`, or the `"default"` sentinel. | @@ -88,6 +90,10 @@ how the tree is worked; every switch there is off by default. | `cursor_effort` | string | `"default"` | Agents | The effort suffix the DAEMON joins onto `cursor_model` into one flat `--model -` id. The choices follow the family, so the overlay row reads `n/a` while the model is unset or has no effort variants. | | `pi_model` | string | `"default"` | Agents | Default `--model` for new Pi sessions. Pi takes a fuzzy pattern across every provider it has credentials for, so the overlay lists families (`opus`, `sonnet`, `haiku`, `gpt-5.5`); a hand-edited `provider/id` such as `anthropic/claude-sonnet-5` passes through verbatim. | | `pi_effort` | string | `"default"` | Agents | Default `--thinking` level for new Pi sessions: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, or the `"default"` sentinel. | +| `muse_model` | string | `"default"` | Agents | Default `--model` for new Muse sessions. Any model id passes through verbatim; `"default"` means don't pass the flag. | +| `muse_effort` | string | `"default"` | Agents | Reserved until the `muse` CLI documents a reasoning flag. Stored, never sent. | +| `custom_harnesses` | array | `[]` | Agents | Extra CLIs the NEW SESSION PICKER offers after the built-ins, each with its own Agents tab section (Enabled and Model rows). Each entry is `{id, program}` plus options: `label` (picker text, defaults to the id), `enabled` (default `true`), `model` (default `"default"` = the CLI's pick, else passed verbatim), `model_flag` (default `"--model"`), and `hooks` (a built-in dialect the program speaks: `claude`, `codex`, `cursor` or `pi` — with one set the sessions report status, prompts and permission waits exactly like that harness, including title sync and auto-title for `claude`; without one they stay process-based, running while the PTY is live and never waiting-on-you). Ids use lowercase letters, digits and hyphens and must not collide with a built-in. Legacy: new harnesses belong in `harnesses`, where they also gain resume, effort, system-prompt and hook-dialect rows. Invalid entries never launch — the picker hides them and the daemon refuses them with the reason. | +| `harnesses` | object | `{}` | Agents | The harness registry: per-harness deltas over the compiled-in known harnesses (Claude, Codex, Cursor, Pi, Muse), and whole new third-party CLIs. The Agents tab grows one section per entry — Enabled, Model, and Effort rows while the harness offers effort — and the `n` picker, `e` presets, spawn, resume and hooks all read the merged rows. A hand edit that breaks one entry refuses its launches with the reason, never the whole file. Run `nebula config harnesses` to print the effective rows to copy from. | | `keybindings` | object | `{}` | Hotkeys | KEYMAP overrides, keyed by action id, valued with a comma-separated chord list: `{"git_diff": "ctrl+g, g"}`. An empty string deliberately unbinds; unknown ids are ignored. Only rows that differ from the defaults are written. | | `prewarm_agents` | bool | `true` | Sessions | DAEMON-owned PREWARM POOL: keep one booted agent CLI standing by in the selected WORKTREE, so creating a session there adopts it and feels instant. **Costs one idle CLI process per warm slot** (150–300 MB each, up to 15 minutes), and that spare is a real session as far as the CLI is concerned — Claude's own `/list-agents` lists it beside the sessions you made, named after the directory (`my-repo-3f`), and the memory modal (`Shift+M`) groups it under **warm spares**. Off drains the pool on the DAEMON's next sweep (within 30 s). | | `prewarm_sessions` | bool | `true` | Sessions | DAEMON-owned SESSION PREWARM: boot a WORKTREE's dead sessions when your selection rests on it, so attaching shows an already-booted screen instead of a booting shell. **Costs idle shell/CLI processes for sessions you may never open.** Off stops booting them; sessions already up stay until the IDLE REAPER takes them. | @@ -114,6 +120,37 @@ CONFIG.JSON work by hand: Turning the pool off takes the standing spares away on the DAEMON's next sweep. `session_idle_timeout` is what bounds the cost of both when they are left on. +### The harness registry + +The five known harnesses ship compiled in, and `harnesses` edits them per field — or adds a new +CLI outright. Only your deltas go in the file; `nebula config harnesses` prints the effective rows +to copy from. Disabling one is one line, and adding a CLI is one block: picker, presets, spawn, +resume, hooks and the Agents tab section all follow, with no rebuild. + +```json +{ + "harnesses": { + "cursor": { "enabled": false }, + "agy": { + "program": "agy", + "model_default": "big-1", + "resume_flag": "--resume", + "hooks": "claude" + } + } +} +``` + +Nullable rows (`program`, `model_flag`, `permissions_flag`, `hooks`, …) clear with `null` — +`"claude": {"hooks": null}` runs Claude with process-based status and no title sync. A row that +stops making sense (an empty program, a resume flag plus a resume subcommand, an unknown dialect) +refuses its launches with the reason while every other harness keeps working. The per-harness keys +the Agents tab edits (`claude_model`, `codex_enabled`, …) keep working as a fallback wherever the +map stays silent. Omit the map entirely and you get every built-in, enabled, with its defaults — +including ones a later nebula adds. Like every other object key, a `harnesses` map in +`config.local.json` replaces the whole map from `config.json` rather than merging per harness, +so keep machine-specific overrides in one layer. + ### What `session_idle_timeout` accepts The overlay cycles `off`, `1m`, `5m`, `15m`, `30m`, `1h`, but the DAEMON parses more than that: any diff --git a/docs/how-it-works.md b/docs/how-it-works.md index e3f0009..6fe85da 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -83,7 +83,7 @@ sweep leave that terminal alone, and a run that exits on its own keeps its PTY, so an attach replays the ending instead of respawning — a command starts only on the keypress. `Shift+Enter` (or `Shift+O`) runs `open` once, from the TUI. See [Configuration](configuration.md#the-project-file-nebulajson). -- **Agents boot `claude`, `codex`, `cursor-agent`, or `pi`.** Creating an agent (`n`) first asks which CLI to +- **Agents boot `claude`, `codex`, `cursor-agent`, `pi`, `muse`, or a custom registry program.** Creating an agent (`n`) first asks which CLI to run, then spawns it in the worktree. Claude's picker can also dispatch a one-shot Cloud task as `claude --cloud `; because Claude accepts that description as a process argument, don't put secrets in the Cloud task. That CLI prints the new session's id and exits, and the DAEMON reads the @@ -92,12 +92,12 @@ Restored agents resume with `claude --resume ` / `codex resume ` / `cursor-agent --resume ` (falling back to a fresh session when the old one is gone) / `pi --session-id ` (which creates a missing id instead of - dying). A session's id is saved only once a turn has run in it — the CLI writes the transcript a + dying); `muse` always boots fresh (no resume flag mapped yet). A session's id is saved only once a turn has run in it — the CLI writes the transcript a resume reads on the first prompt — so a CLI booted and never used resumes as nothing. Claude ids are checked against the transcripts on disk before the spawn, and one with none boots fresh; any resume that exits with an error within 10 s of its spawn is respawned fresh, unless its Claude transcript is still there (then the id is kept, and the pane shows why the CLI quit). An AGENT created from a PROJECT OPEN PRS row also receives the PR URL and a PR-only - work rule — Claude and Pi through `--append-system-prompt` on every spawn, Codex and Cursor as the first prompt of + work rule — Claude and Pi through `--append-system-prompt` on every spawn, Codex, Cursor and Muse as the first prompt of their cold spawn (their transcripts carry it through a resume); nebula persists that URL. An AGENT launched from the ISSUES MODAL (`i`) carries the GitHub issue's URL the same way — persisted with the row, rebuilt into an issue-context rule on every spawn and resume — so the harness knows which @@ -196,15 +196,15 @@ worktree, opening with a note saying where it now runs, so the conversation carries on there without you typing anything. Claude learns the rule from a short `--append-system-prompt` nebula passes at spawn, plus a `Bash(nebula worktree:*)` permission so the command never prompts; Pi gets the same - appended prompt and reopens on the same note. Codex and Cursor have no system-prompt flag to learn + appended prompt and reopens on the same note. Codex, Cursor and Muse have no system-prompt flag to learn the rule from, but run the same command when you ask. Codex then reopens on the same note — `codex resume --cd ""`, the `--cd` because Codex otherwise reopens a resumed session in the directory its transcript recorded, the old checkout. Cursor resumes silent and waits - for your next prompt. The restart is the only way there: an agent CLI can't `cd` out of the + for your next prompt; Muse reboots fresh with no note (no resume flag mapped). The restart is the only way there: an agent CLI can't `cd` out of the directory it was started in. - **Ask the agent for another session and it starts one.** Tell a Claude session "start a new nebula session that fixes the login redirect" and it runs `nebula spawn ""`: the daemon starts a second - agent beside it — same worktree, same harness, model and effort unless `--kind claude|codex|cursor|pi` + agent beside it — same worktree, same harness, model and effort unless `--kind claude|codex|cursor|pi|muse` names another — opening on that task as its first prompt, so it is working before you look. The new row appears in the sessions list on its own (default name, so it titles itself), and the session you asked from is untouched: no restart, no focus change. Claude learns this from the same appended system diff --git a/docs/keys.md b/docs/keys.md index 74c52e7..598e021 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -45,6 +45,7 @@ The panels aren't the only view. With a worktree selected, from any panel: | Sessions | `n` | new session: pick the harness (`→` drills into its model and effort, `Enter` takes your defaults), then the same task box `p` opens (below) asks for the agent's first prompt — `Enter` launches on what you typed, or on nothing at all to start in the CLI and type it there. The session titles itself from that first prompt either way, and the launch drops you into its pane. **Skip starting prompt** (Settings → Sessions; `skip_session_naming` in CONFIG.JSON) launches straight from the picker instead. A shell terminal is `t` | | Sessions (any row) | `Ctrl+d` / `Ctrl+u` | half a panel down / up, the Worktrees rule on this column: the cursor jumps by half the pill rows the column has room for — live sessions, terminals, the PR ROW and the `ARCHIVED` group's rows as one list — stopping at the first and last row, with the list scrolling after it so the row landed on is in view and the pane previewing what it lands on. The jump is sized by pills, so RECENT PROMPTS lines under live rows are not billed: over them it moves a little more than half a panel, over the archived rows it is for exactly half. Same rebindable actions, `half_page_down` / `half_page_up` | | New session picker (Claude) | `Tab` | toggle Claude Cloud; Cloud's own wrapped task prompt (`Shift+Enter` or `Ctrl+J` inserts a line) replaces the box above and needs a task | +| New session picker (any row) | `?` or `s` | open Settings → Agents parked on the highlighted harness's section, where its Enabled switch and default model and effort live (`s` types in the model/effort submenus instead, where the filter owns letters) | | Sessions (cloud row) | `Enter`, `m` | `Enter` — or a click on the link in the CLOUD SESSION PANEL — opens the session's page in the browser; the `m` menu offers **Open in browser** and **Send to cloud session**, which queues a message on it. No **Attach** or **Restart**: the agent runs in the cloud, not in a terminal here | | Sessions | `r`, `a`, `u`, `d`, `A` | rename, archive, unarchive, delete, toggle archived. `Delete` and `Backspace` are bound alongside `d` and do the same thing, here and everywhere else `d` deletes a row (a project, a worktree) — and archive, cheap to undo with `u`, is the one verb here that skips the CONFIRM DIALOG `d` goes behind, unless **Confirm on archive** (Settings → Sessions; `confirm_on_archive` in CONFIG.JSON, off by default) is on: then `a` and the row menu's Archive ask first, for when typing aimed at an agent keeps landing on this panel. On the PR ROW, which has nothing to rename, `r` only says so; `Shift+R` refreshes the pull requests (above) | | Sessions | `e` | agent presets: saved launch definitions (harness, model, effort, optional prefix/postfix text, and whether to ask for a task). `Enter` asks for an optional task and starts the agent with prefix + task + postfix as its first prompt — sent empty, the prefix and postfix go alone — or, for a preset whose **Task** row is `skip`, launches at once without asking; `a` / `e` / `d` create, edit, delete | diff --git a/docs/sessions.md b/docs/sessions.md index 6d7aad5..a7e4685 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -7,8 +7,15 @@ Everything that can start an AGENT, and what each launch path does differently. ## The NEW SESSION PICKER With a WORKTREE selected, press `n` in the SESSIONS PANEL. A menu asks what to -run — **Claude**, **Codex**, **Cursor**, or **Pi** (a plain shell is `t` — see [Keys](keys.md)); a CLI you never use can be -switched off on the settings overlay's Agents tab and drops out of the menu entirely. `→` on any row drills +run — **Claude**, **Codex**, **Cursor**, **Pi**, or **Muse** (a plain shell is `t` — see [Keys](keys.md)); a CLI you never use can be +switched off on the settings overlay's Agents tab and drops out of the menu entirely. Turn on `Hide missing CLIs` +on the Agents tab and the menu lists only enabled harnesses whose CLI is found on PATH (the daemon still +checks through the login shell at launch). Your own CLIs join the menu too: add them to config.json +`custom_harnesses` (see [Configuration](configuration.md)) and they appear after the built-ins under their +own labels, toggled per entry on the Agents tab's Custom harnesses row, with the session row wearing the +entry's label as its badge. A custom entry launches with its program and model flag, boots fresh every +time (no resume mapping), and — unless it names a built-in hook dialect — stays process-based: yellow +while the PTY is live, green when it ends, never red. `→` on any row drills into model and reasoning-effort submenus (Cursor's model is a family such as `claude-opus-5-thinking`, and its effort list follows the family, `-fast` variants included — `cursor-agent --list-models` bakes both into the id, so nebula launches `--model claude-opus-5-thinking-high-fast`; the list is a built-in seed @@ -42,7 +49,8 @@ is spawned with no permission flag at all and keeps its normal prompts — it st things it is configured to ask about. Codex is spawned with `--yolo` and Cursor with `--force`, so **neither of those two ever stops to ask**: they edit files and run commands on their own judgment for the life of the SESSION, and nothing in the picker or the settings overlay softens that. Pi has no -permission gate to begin with — nebula passes no flag, and it runs its tools as it sees fit. Pick the +permission gate to begin with — nebula passes no flag, and it runs its tools as it sees fit. Muse is +the same: no flag mapped yet. Pick the harness with that in mind, especially in the ROOT WORKTREE. The same choice reaches the STATUS DOT, because an AGENT can only report what its hook set can see. @@ -62,7 +70,8 @@ nebula installs one managed extension (`~/.pi/agent/extensions/nebula.ts`, inert posts pi's `session_start`, `before_agent_start`, `agent_end` and `ask_question` tool events as `SessionStart`, `UserPromptSubmit`, `Stop` and `PreToolUse` / `PostToolUse`, and any blocking prompt an extension raises mid-run as `PermissionRequest` — so a Pi row goes yellow, red while its `ask_question` -tool waits on you, and green when the run ends, a cancelled run included. +tool waits on you, and green when the run ends, a cancelled run included. Muse has no hooks at all yet: +its row is yellow while the PTY is live and green when the process ends, and it never goes red. ## AGENT PRESETS @@ -77,7 +86,9 @@ and `Enter` launches it at once, no box at all; the list marks those rows `no ta picked with `Shift+Tab` in a quick prompt, or with `e` in the issues modal, launches the same way when the box is still empty, while text you already typed stays yours to send. The row it creates is an ordinary session: it names itself on that first turn, resumes, and shows status like any other. Presets -live in `agent_presets.json` beside `config.json`. +live in `agent_presets.json` beside `config.json`. The form's Harness row lists custom registry entries +by id alongside the built-ins — a preset on one launches with the entry's program and defaults, and +refuses with the reason when its entry is switched off or gone. ## RECENT PROMPTS @@ -120,10 +131,10 @@ list on its own, and the one under your cursor goes the moment GitHub says it's on one and the right-hand pane reads it to you — description, stats and the whole conversation — without leaving nebula; `g` opens its diff in the same viewer your worktree diffs use, `Enter` or a double-click opens it in the browser, and `/` finds it by title. Press `n` — or choose **New Claude session**, **New -Codex session** or **New Cursor session** from `m` / right-click — to start a SESSION on any enabled +Codex session**, **New Cursor session**, **New Pi session** or **New Muse session** from `m` / right-click — to start a SESSION on any enabled harness in the PROJECT's ROOT WORKTREE, through the same MODEL / EFFORT submenus as the NEW SESSION -PICKER, with a rule that limits all work to that PR and includes its URL: Claude gets it as an appended -system prompt, Codex and Cursor as their first prompt. The URL is kept with the AGENT, so RESUME +PICKER, with a rule that limits all work to that PR and includes its URL: Claude and Pi get it as an appended +system prompt, Codex, Cursor and Muse as their first prompt. The URL is kept with the AGENT, so RESUME reapplies the same scope. Only the row you actually stop on is fetched. The group folds. Click its header — or pick **Show/hide open PRs** from the panel's right-click @@ -172,7 +183,7 @@ Either way the launch is an ISSUE SESSION. The create carries the issue's URL (`CreateAgent::issue_url`); the DAEMON validates it, keeps it with the AGENT row beside a PR SESSION's URL, refuses to hand the launch to a PREWARM POOL spare (which booted without it), and on every cold spawn and RESUME composes an issue-context rule naming the URL, the checkout and its -branch — Claude and Pi receive it through `--append-system-prompt`, Codex and Cursor as the opening +branch — Claude and Pi receive it through `--append-system-prompt`, Codex, Cursor and Muse as the opening of their first prompt, exactly as the PR rule travels. The harness therefore knows which issue the session exists for before it reads your task, is told to read the issue with `gh issue view` first, and to reference it in commits and close it from the pull request. The row it creates is an