Skip to content
Open
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
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Every project is in one list — there is no grouping above it. One repo is one

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`, `cursor-agent`, `pi`, `muse`, `grok`, or `opencode` in that worktree. Restart resumes the stored session id when there is one, the way each CLI takes it (`--resume <id>` for Claude, Cursor and Grok; `codex resume <id>`, `pi --session-id <id>`, `opencode --session <id>`; muse always boots fresh: no resume flag mapped yet).
An agent is a PTY running `claude`, `codex`, `cursor-agent`, `pi`, `muse`, `grok`, `opencode`, or `copilot` in that worktree. Restart resumes the stored session id when there is one, the way each CLI takes it (`--resume <id>` for Claude, Cursor, Grok and Copilot; `codex resume <id>`, `pi --session-id <id>`, `opencode --session <id>`; muse always boots fresh: no resume flag mapped yet).

Persistence is SQLite at `~/.local/share/nebula/nebula.db`: projects, worktrees, agents (kind + CLI session id, plus a PR URL when the AGENT was created from one), links, last UI selection.

Expand Down
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

**Mission control for your coding agents.**

Run **Claude Code**, **Codex**, **Cursor**, **Pi**, **Muse**, **Grok Build** and **OpenCode** across every project and git WORKTREE you own — from one
Run **Claude Code**, **Codex**, **Cursor**, **Pi**, **Muse**, **Grok Build**, **OpenCode** and **GitHub Copilot** across every project and git WORKTREE you own — from one
terminal, one keyboard, one grid. 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 @@ -89,6 +89,7 @@ re-checks through the login shell at launch.
| <img src="https://www.google.com/s2/favicons?domain=meta.com&sz=128" width="24" height="24" alt="Muse"> | [Muse](https://developer.meta.com/ai/lp/muse-code) | `muse` | `curl -fsSL https://dev.meta.ai/install.sh \| bash` |
| <img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=128" width="24" height="24" alt="OpenCode"> | [OpenCode](https://opencode.ai/docs/) | `opencode` | `curl -fsSL https://opencode.ai/install \| bash` |
| <img src="https://www.google.com/s2/favicons?domain=x.ai&sz=128" width="24" height="24" alt="Grok"> | [Grok](https://github.com/xai-org/grok-build) | `grok` | `curl -fsSL https://x.ai/cli/install.sh \| bash` |
| <img src="https://www.google.com/s2/favicons?domain=github.com&sz=128" width="24" height="24" alt="Copilot"> | [Copilot](https://github.com/github/copilot-cli) | `copilot` | `npm i -g @github/copilot` |

## Install

Expand All @@ -113,7 +114,7 @@ WSL2 is supported as Linux. WSL1 is not: it lacks the Linux kernel behavior nebu
for its Unix socket, pidfile lock, and PTYs, so nebula reports that directly and tells you to convert
the distro with `wsl.exe --set-version <distro> 2`.

> **Prerequisite:** at least one agent CLI on your `PATH` — `claude`, `codex`, `cursor-agent`, `pi`, `muse`, `grok`, or `opencode`.
> **Prerequisite:** at least one agent CLI on your `PATH` — `claude`, `codex`, `cursor-agent`, `pi`, `muse`, `grok`, `opencode`, or `copilot`.
> nebula spawns them; it doesn't ship them.
>
> Four commands each want one more binary, and only those commands: `nebula ssh` and `nebula tunnel`
Expand Down Expand Up @@ -145,7 +146,7 @@ project on the machine, and `x` closes a tab without touching its sessions.
`Enter` launches it — with the harness, model and effort the Agents tab defaults name, in the checkout
under the cursor — and the cursor and the pane land on the new card as it shows up in the grid
(turn off **Follow new** in Settings → Agents to stay on the card you were on). For one launch
only, `Tab` picks another harness (**Claude**, **Codex**, **Cursor**, **Pi**, **Muse**, **Grok Build** or **OpenCode**, `→`
only, `Tab` picks another harness (**Claude**, **Codex**, **Cursor**, **Pi**, **Muse**, **Grok Build**, **OpenCode** or **Copilot**, `→`
for MODEL and EFFORT), `Ctrl+O` a model, `Ctrl+T` any checkout of the project, `Ctrl+P` any project on the
machine (a launch aimed elsewhere runs in the background and the footer says where it went). Send the
box empty and the CLI starts bare, its pane yours to type the first prompt into. Save a framing you
Expand Down Expand Up @@ -203,6 +204,9 @@ with no managed hooks or automatic capture of its session ID yet. Model and effo
set through `harnesses.grok` in config.json; the CLI supplies their defaults when unset. An OpenCode
SESSION does go red: nebula passes no `--auto`, so `opencode` keeps its own permission prompts, and its
managed plugin reports each one (and each `question` the agent asks you) as it opens and closes.
A Copilot SESSION goes red the same way: `copilot` runs the hooks nebula installs for Claude, so its
permission prompts and questions are reported as they open. See
[GitHub Copilot CLI](docs/configuration.md#github-copilot-cli).

The PROJECT TABS ROLL UP their sessions: each tab carries one dot per state its sessions are in, with
the count and no word at all — red waiting on you, blue finished unread, yellow mid-turn, in that order
Expand Down
12 changes: 11 additions & 1 deletion crates/nebula-core/src/entities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ pub enum AgentKind {
/// its positional is the project path, and a session resumes by
/// `--session <id>`.
OpenCode,
/// GitHub Copilot CLI: the `copilot` CLI (npm `@github/copilot`). It
/// reads Claude Code's hook files and fires Claude-shaped payloads, so
/// its status rides the Claude hook dialect; the first prompt rides
/// `-i`, since it takes no positional prompt, and a session resumes by
/// `--resume <id>`.
Copilot,
/// 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
Expand All @@ -81,14 +87,15 @@ impl AgentKind {
/// boot-time CLI probe warm) and should fail to compile if one is added.
/// `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; 8] = [
pub const ALL: [AgentKind; 9] = [
AgentKind::Claude,
AgentKind::Codex,
AgentKind::Cursor,
AgentKind::Pi,
AgentKind::Muse,
AgentKind::Grok,
AgentKind::OpenCode,
AgentKind::Copilot,
AgentKind::Custom,
];

Expand All @@ -101,6 +108,7 @@ impl AgentKind {
AgentKind::Muse => "muse",
AgentKind::Grok => "grok",
AgentKind::OpenCode => "opencode",
AgentKind::Copilot => "copilot",
AgentKind::Custom => "custom",
}
}
Expand All @@ -117,6 +125,7 @@ impl AgentKind {
"muse" => AgentKind::Muse,
"grok" => AgentKind::Grok,
"opencode" => AgentKind::OpenCode,
"copilot" => AgentKind::Copilot,
_ => return None,
})
}
Expand All @@ -136,6 +145,7 @@ impl AgentKind {
AgentKind::Muse => "muse",
AgentKind::Grok => "grok",
AgentKind::OpenCode => "opencode",
AgentKind::Copilot => "copilot",
AgentKind::Custom => "custom",
}
}
Expand Down
75 changes: 67 additions & 8 deletions crates/nebula-core/src/harness.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! The harness registry: one behavior descriptor per agent CLI.
//!
//! The seven known harnesses ship as a compiled-in table ([`builtin`]);
//! The eight 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
Expand Down Expand Up @@ -577,6 +577,51 @@ pub fn builtin(id: &str) -> Option<HarnessDescriptor> {
hooks: Some("opencode".into()),
..base
},
// GitHub Copilot CLI (verified on copilot 1.0.108). It reads hooks
// out of `.claude/settings.local.json` and fires them with Claude's
// event names and payloads — `session_id`, `prompt`, `cwd`, the
// `permission_prompt` / `elicitation_dialog` notifications, and its
// `ask_user` tool reported as `AskUserQuestion` — so it speaks the
// Claude dialect as installed; only the `UserPromptSubmit` reply
// differs, and a permission prompt is read off its notification
// (see the daemon's `hooks::copilot_prompt_reply` and
// `hooks::copilot_event`). No permission flag: its prompts drive
// NEEDS FEEDBACK through those hooks (`--allow-all` is one
// `permissions_flag` override away). It reads no permission rules
// from Claude's settings file, so the rule that lets the AUTO-TITLE
// `nebula rename` run unprompted rides the launch instead. It
// takes no positional prompt, so the first one rides `-i`, which
// survives the folder-trust dialog a fresh checkout opens on. No
// system-prompt flag. The model list is the account's, so none is
// compiled in: `auto` or any id `/model` shows passes verbatim.
// `--resume` reopens a session in the directory it recorded, not
// the one it is launched from, so no relocation notice.
"copilot" => HarnessDescriptor {
label: "Copilot".into(),
program: "copilot".into(),
// `=`: the flag is variadic and would take a following word.
launch_args: vec!["--allow-tool=shell(nebula:*)".into()],
model: ModelSpec {
flag: Some("--model".into()),
..ModelSpec::default()
},
effort: EffortSpec {
flag: Some("--reasoning-effort".into()),
efforts: ["none", "minimal", "low", "medium", "high", "xhigh", "max"]
.iter()
.map(ToString::to_string)
.collect(),
offered: true,
..EffortSpec::default()
},
prompt_flag: Some("-i".into()),
resume: ResumeSpec {
flag: Some("--resume".into()),
..ResumeSpec::default()
},
hooks: Some("claude".into()),
..base
},
_ => return None,
})
}
Expand Down Expand Up @@ -772,7 +817,7 @@ pub struct CustomHarness {
/// 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`, `opencode`).
/// `pi`, `muse`, `opencode`, `copilot`).
pub id: String,
/// Display label for the picker and session rows. Empty falls back
/// to the id.
Expand Down Expand Up @@ -992,7 +1037,7 @@ mod tests {
#[test]
fn builtins_cover_every_builtin_kind() {
let all = builtins();
assert_eq!(all.len(), 7);
assert_eq!(all.len(), 8);
for kind in AgentKind::ALL {
if kind == AgentKind::Custom {
continue;
Expand Down Expand Up @@ -1041,6 +1086,16 @@ mod tests {
assert!(!opencode.effort.offered, "no effort flag, no Effort row");
assert!(!opencode.relocation_prompt);
assert!(!opencode.claude_like());
let copilot = builtin("copilot").unwrap();
assert_eq!(copilot.program, "copilot");
assert_eq!(copilot.launch_args, ["--allow-tool=shell(nebula:*)"]);
assert_eq!(copilot.prompt_flag.as_deref(), Some("-i"));
assert_eq!(copilot.resume.flag.as_deref(), Some("--resume"));
assert_eq!(copilot.effort.flag.as_deref(), Some("--reasoning-effort"));
assert_eq!(copilot.hook_dialect(), Some(AgentKind::Claude));
assert_eq!(copilot.permissions_flag, None, "its prompts drive red");
assert_eq!(copilot.system_append_flag(), None);
assert!(!copilot.relocation_prompt);
}

#[test]
Expand Down Expand Up @@ -1125,13 +1180,16 @@ mod tests {
let ids: Vec<&str> = all.iter().map(|entry| entry.id.as_str()).collect();
assert_eq!(
ids,
vec!["claude", "codex", "cursor", "pi", "muse", "grok", "opencode", "zed", "agy"]
vec![
"claude", "codex", "cursor", "pi", "muse", "grok", "opencode", "copilot", "zed",
"agy"
]
);
assert!(!all[0].enabled, "the claude override applied");
assert_eq!(all[7].program, "agy", "legacy entry converts");
assert_eq!(all[8].program, "agy", "map-only entry resolves");
assert_eq!(all[8].program, "agy", "legacy entry converts");
assert_eq!(all[9].program, "agy", "map-only entry resolves");
assert!(
!all[8].effort.offered,
!all[9].effort.offered,
"a bare program stays Model-only like a legacy custom"
);
}
Expand Down Expand Up @@ -1194,7 +1252,7 @@ mod tests {
broken.program = " ".into();
let all = registry(&BTreeMap::new(), &[broken]);
assert!(resolve(&all, AgentKind::Custom, Some("broken")).is_err());
assert_eq!(usable(&all).len(), 7, "the broken entry is hidden");
assert_eq!(usable(&all).len(), 8, "the broken entry is hidden");
}

#[test]
Expand All @@ -1205,6 +1263,7 @@ mod tests {
assert!(custom("muse").problem().is_some());
assert!(custom("grok").problem().is_some());
assert!(custom("opencode").problem().is_some());
assert!(custom("copilot").problem().is_some());
let claude_hooks = CustomHarness {
hooks: Some("claude".into()),
..custom("agy")
Expand Down
2 changes: 1 addition & 1 deletion crates/nebula-core/src/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use std::path::PathBuf;
///
/// Bump on every protocol change. Additive changes keep
/// [`MIN_COMPATIBLE_PROTOCOL`] where it is; breaking changes bump both.
pub const PROTOCOL_VERSION: u32 = 51;
pub const PROTOCOL_VERSION: u32 = 52;

/// Oldest IPC protocol this build can safely talk to.
///
Expand Down
Loading