Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Exactly one workspace is *open* at a time — daemon-global state, switched with

Worktrees are real git worktrees, created under `<repo>/../<repo-name>-worktrees/<branch>`. 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 <session-id>` when one is stored.
An agent is a PTY running `claude`, `codex`, `cursor-agent`, `pi`, or `muse` in that worktree. Restart uses `--resume <session-id>` 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.

Expand Down Expand Up @@ -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 `<data dir>/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 `<data dir>/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 … "<text>"`, `codex … "<text>"`, `cursor-agent … "<text>"`). 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 `<data dir>/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 … "<text>"`, `codex … "<text>"`, `cursor-agent … "<text>"`, `pi … "<text>"`, `muse … "<text>"`). 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 `<data dir>/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.

Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
32 changes: 31 additions & 1 deletion crates/nebula-core/src/entities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<Self> {
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",
}
}
}
Expand Down Expand Up @@ -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<String>,
/// Model the CLI is launched with (claude `--model` / codex `-m`);
/// None = the CLI's own default. Persisted so respawns keep it.
#[serde(default)]
Expand Down
Loading
Loading