diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a524e705..82d55ace 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 `/../-worktrees/`. 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 ` for Claude, Cursor and Grok; `codex resume `, `pi --session-id `, `opencode --session `; 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 ` for Claude, Cursor, Grok and Copilot; `codex resume `, `pi --session-id `, `opencode --session `; 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. diff --git a/README.md b/README.md index 1ebaa12f..4803a91a 100644 --- a/README.md +++ b/README.md @@ -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) @@ -89,6 +89,7 @@ re-checks through the login shell at launch. | Muse | [Muse](https://developer.meta.com/ai/lp/muse-code) | `muse` | `curl -fsSL https://dev.meta.ai/install.sh \| bash` | | OpenCode | [OpenCode](https://opencode.ai/docs/) | `opencode` | `curl -fsSL https://opencode.ai/install \| bash` | | Grok | [Grok](https://github.com/xai-org/grok-build) | `grok` | `curl -fsSL https://x.ai/cli/install.sh \| bash` | +| Copilot | [Copilot](https://github.com/github/copilot-cli) | `copilot` | `npm i -g @github/copilot` | ## Install @@ -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 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` @@ -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 @@ -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 diff --git a/crates/nebula-core/src/entities.rs b/crates/nebula-core/src/entities.rs index 482be2a3..e7742a97 100644 --- a/crates/nebula-core/src/entities.rs +++ b/crates/nebula-core/src/entities.rs @@ -69,6 +69,12 @@ pub enum AgentKind { /// its positional is the project path, and a session resumes by /// `--session `. 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 `. + 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 @@ -81,7 +87,7 @@ 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, @@ -89,6 +95,7 @@ impl AgentKind { AgentKind::Muse, AgentKind::Grok, AgentKind::OpenCode, + AgentKind::Copilot, AgentKind::Custom, ]; @@ -101,6 +108,7 @@ impl AgentKind { AgentKind::Muse => "muse", AgentKind::Grok => "grok", AgentKind::OpenCode => "opencode", + AgentKind::Copilot => "copilot", AgentKind::Custom => "custom", } } @@ -117,6 +125,7 @@ impl AgentKind { "muse" => AgentKind::Muse, "grok" => AgentKind::Grok, "opencode" => AgentKind::OpenCode, + "copilot" => AgentKind::Copilot, _ => return None, }) } @@ -136,6 +145,7 @@ impl AgentKind { AgentKind::Muse => "muse", AgentKind::Grok => "grok", AgentKind::OpenCode => "opencode", + AgentKind::Copilot => "copilot", AgentKind::Custom => "custom", } } diff --git a/crates/nebula-core/src/harness.rs b/crates/nebula-core/src/harness.rs index 5b4b2175..f3d61d03 100644 --- a/crates/nebula-core/src/harness.rs +++ b/crates/nebula-core/src/harness.rs @@ -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 @@ -577,6 +577,51 @@ pub fn builtin(id: &str) -> Option { 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, }) } @@ -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. @@ -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; @@ -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] @@ -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" ); } @@ -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] @@ -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") diff --git a/crates/nebula-core/src/protocol.rs b/crates/nebula-core/src/protocol.rs index 8348fea6..6368bc68 100644 --- a/crates/nebula-core/src/protocol.rs +++ b/crates/nebula-core/src/protocol.rs @@ -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. /// diff --git a/crates/nebula-daemon/src/hooks/mod.rs b/crates/nebula-daemon/src/hooks/mod.rs index 3ee64233..af08a55f 100644 --- a/crates/nebula-daemon/src/hooks/mod.rs +++ b/crates/nebula-daemon/src/hooks/mod.rs @@ -73,6 +73,38 @@ pub fn user_prompt_reply(instruction: bool, session_title: Option<&str>) -> Stri serde_json::json!({ "hookSpecificOutput": output }).to_string() } +/// The AUTO-TITLE instruction as Copilot CLI reads it. Copilot fires +/// Claude's hooks from Claude's files, but takes injected context only from +/// a top-level `additionalContext` — Claude's `hookSpecificOutput` envelope +/// is parsed and dropped (verified on copilot 1.0.108) — and has no +/// `sessionTitle` to take a name from, so there is nothing but the +/// instruction to send. +pub fn copilot_prompt_reply(instruction: bool) -> String { + if !instruction { + return String::new(); + } + serde_json::json!({ "additionalContext": AUTO_TITLE_INSTRUCTION }).to_string() +} + +/// A Claude-route event as it reads for a Copilot CLI row. Copilot fires +/// `PermissionRequest` for every tool call it weighs, including the ones a +/// rule or `--allow-all` approves with no dialog, so that hook says nothing +/// about a wait and is dropped (`None`). Its `permission_prompt` +/// notification fires only as a dialog opens, and at once — not deferred +/// like Claude's — so it is the permission wait (verified on copilot +/// 1.0.108). +pub fn copilot_event(event: HookEvent) -> Option { + match event { + HookEvent::PermissionRequest { .. } => None, + HookEvent::Notification { notification_type } + if notification_type.as_deref() == Some("permission_prompt") => + { + Some(HookEvent::PermissionRequest { subagent_id: None }) + } + other => Some(other), + } +} + /// Diagnostic bodies for the route whose responses a hook discards. Never /// sent where a body would reach the model's context (see `HookDialect`). pub const HOOK_OK: &str = r#"{"ok": true}"#; @@ -406,6 +438,21 @@ async fn receive_hook( } } } + // Copilot CLI runs the Claude hooks and posts to the Claude route, but + // its permission events and its reply shape are its own. + let copilot = cli == HookCli::Claude + && matches!( + state.store.get_agent(&agent_id), + Ok(Some(agent)) if agent.kind == nebula_core::AgentKind::Copilot + ); + let event = if copilot { + match copilot_event(event) { + Some(event) => event, + None => return quiet_or(StatusCode::OK, HOOK_OK), + } + } else { + event + }; tracing::debug!(agent = %agent_id, event = ?event, "hook received"); // A subagent's tool traffic reports the payload's cwd too, but that is // the Task's position, not the session's — an isolated subagent working @@ -480,6 +527,9 @@ async fn receive_hook( }), HookCli::Codex | HookCli::Cursor | HookCli::Pi | HookCli::OpenCode => None, }; + if copilot { + return (StatusCode::OK, copilot_prompt_reply(inject)); + } return ( StatusCode::OK, user_prompt_reply(inject, session_title.as_deref()), @@ -598,6 +648,120 @@ mod tests { } } + /// Copilot CLI runs the Claude hooks and posts to the Claude route, + /// but only a top-level `additionalContext` reaches its model: the + /// AUTO-TITLE instruction goes out in that shape while it is due, and + /// a named row sends nothing (Copilot takes no `sessionTitle`). + #[tokio::test] + async fn copilot_rows_get_the_instruction_in_copilots_reply_shape() { + let store = seeded_store(); + let template = store + .get_agent(&AgentId("pending".into())) + .unwrap() + .unwrap(); + let copilot = |id: &str, name: &str| Agent { + id: AgentId(id.into()), + name: name.into(), + kind: AgentKind::Copilot, + ..template.clone() + }; + store + .insert_agent_with_auto_title(&copilot("copilot-pending", "agent-7"), true) + .unwrap(); + store + .insert_agent(&copilot("copilot-named", "Fix Login")) + .unwrap(); + let (env, mut rx, _) = start_hook_server(store, None).await.unwrap(); + let payload = r#"{"session_id":"c1","prompt":"fix the login redirect"}"#; + + let (status, body) = http_post( + env.port, + "/api/hooks/claude?agentId=copilot-pending&hookEvent=UserPromptSubmit", + &env.token, + payload, + ) + .await; + assert_eq!(status, 200); + let parsed: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(parsed["additionalContext"], AUTO_TITLE_INSTRUCTION); + assert!(parsed.get("hookSpecificOutput").is_none()); + let delivery = rx.try_recv().unwrap(); + assert_eq!(delivery.session_id.as_deref(), Some("c1")); + assert_eq!(delivery.prompt.as_deref(), Some("fix the login redirect")); + + let (status, body) = http_post( + env.port, + "/api/hooks/claude?agentId=copilot-named&hookEvent=UserPromptSubmit", + &env.token, + payload, + ) + .await; + assert_eq!(status, 200); + assert!(body.is_empty(), "no sessionTitle for Copilot: {body}"); + } + + /// Copilot fires `PermissionRequest` for tool calls no dialog ever + /// opens for; only its `permission_prompt` notification is a wait. + #[tokio::test] + async fn copilot_permission_wait_is_read_off_the_notification() { + let store = seeded_store(); + let template = store.get_agent(&AgentId("titled".into())).unwrap().unwrap(); + store + .insert_agent(&Agent { + id: AgentId("copilot".into()), + kind: AgentKind::Copilot, + ..template + }) + .unwrap(); + let (env, mut rx, _) = start_hook_server(store, None).await.unwrap(); + let post = |agent: &'static str, event: &'static str, body: &'static str| { + let (port, token) = (env.port, env.token.clone()); + async move { + http_post( + port, + &format!("/api/hooks/claude?agentId={agent}&hookEvent={event}"), + &token, + body, + ) + .await + } + }; + + let (status, _) = post("copilot", "PermissionRequest", r#"{"sessionId":"c1"}"#).await; + assert_eq!(status, 200); + assert!(rx.try_recv().is_err(), "an auto-approved call is no wait"); + + post( + "copilot", + "Notification", + r#"{"sessionId":"c1","notification_type":"permission_prompt"}"#, + ) + .await; + assert_eq!( + rx.try_recv().unwrap().event, + HookEvent::PermissionRequest { subagent_id: None } + ); + + // A question keeps its own notification, and Claude rows keep both. + post( + "copilot", + "Notification", + r#"{"notification_type":"elicitation_dialog"}"#, + ) + .await; + assert_eq!( + rx.try_recv().unwrap().event, + HookEvent::Notification { + notification_type: Some("elicitation_dialog".into()) + } + ); + post("titled", "PermissionRequest", r#"{"session_id":"s1"}"#).await; + assert_eq!( + rx.try_recv().unwrap().event, + HookEvent::PermissionRequest { subagent_id: None } + ); + } + /// The reply's `sessionTitle` is how a nebula-side name reaches /// Claude: sent on the Claude route for a settled name Claude doesn't /// hold, never for a default name, never on the Codex route, and no diff --git a/crates/nebula-daemon/src/registry.rs b/crates/nebula-daemon/src/registry.rs index c8a4fb10..b2abe0e5 100644 --- a/crates/nebula-daemon/src/registry.rs +++ b/crates/nebula-daemon/src/registry.rs @@ -1595,6 +1595,65 @@ mod tests { assert_eq!(grok.permissions_flag, None); } + /// Flags verified against copilot 1.0.108: `-i` carries the first + /// prompt (the CLI takes no positional one), `--resume ` reopens a + /// session, and the hooks are Claude's. + #[test] + fn copilot_spawn_uses_verified_flags_and_the_claude_hook_dialect() { + let copilot = nebula_core::harness::builtin("copilot").unwrap(); + assert_eq!( + agent_spawn_command_with(&copilot, None, None, None, None, None, None, None, false), + ( + "copilot".into(), + vec!["--allow-tool=shell(nebula:*)".into()], + false + ) + ); + let (program, args, resumed) = agent_spawn_command_with( + &copilot, + None, + Some(Path::new(TEST_CWD)), + Some("model-id"), + Some("high"), + None, + Some("fix the bug; keep this as one argument"), + None, + true, + ); + assert_eq!(program, "copilot"); + assert!(!resumed); + assert_eq!( + args, + vec![ + "--allow-tool=shell(nebula:*)", + "--model", + "model-id", + "--reasoning-effort", + "high", + "-i", + "fix the bug; keep this as one argument", + ] + ); + let (_, args, resumed) = agent_spawn_command_with( + &copilot, + Some("session-id"), + Some(Path::new(TEST_CWD)), + None, + None, + None, + None, + None, + true, + ); + assert!(resumed); + assert_eq!( + args, + vec!["--allow-tool=shell(nebula:*)", "--resume", "session-id"] + ); + assert_eq!(copilot.hook_dialect(), Some(AgentKind::Claude)); + assert_eq!(copilot.permissions_flag, None); + } + #[test] fn spawn_command_initial_prompt_is_the_trailing_positional_argument() { let all = test_registry(); diff --git a/crates/nebula-daemon/src/sibling.rs b/crates/nebula-daemon/src/sibling.rs index 70a9f200..0558a4ac 100644 --- a/crates/nebula-daemon/src/sibling.rs +++ b/crates/nebula-daemon/src/sibling.rs @@ -23,7 +23,7 @@ another session to …\", \"open a new session for …\", \"hand this off\"), do process yourself. Run this shell command instead, exactly once:\n\n nebula spawn \"\"\n\nwhere \ is the work the user wants that session to do, in their own words — the new session opens on \ it as its first prompt, so make it self-contained. Add `--kind \ -claude|codex|cursor|pi|muse|grok|opencode` only when the user names the harness; otherwise the new \ +claude|codex|cursor|pi|muse|grok|opencode|copilot` only when the user names the harness; otherwise the new \ session matches this one. nebula starts it beside this session, in the same worktree, and it shows \ up in the sessions list on its own. When the user wants that separate new/parallel session on its \ own branch or worktree (\"start a new session on branch fix-login\", \"spin up an agent in a new \ diff --git a/crates/nebula-tui/src/agent_picker.rs b/crates/nebula-tui/src/agent_picker.rs index 3233f9b3..0dc40609 100644 --- a/crates/nebula-tui/src/agent_picker.rs +++ b/crates/nebula-tui/src/agent_picker.rs @@ -259,6 +259,7 @@ pub(crate) fn kind_label(kind: AgentKind) -> &'static str { AgentKind::Muse => "Muse", AgentKind::Grok => "Grok Build", AgentKind::OpenCode => "OpenCode", + AgentKind::Copilot => "Copilot", // Custom rows label through `harness_label` (the entry's own // label); this is only the fallback when its entry is gone. AgentKind::Custom => "Custom", @@ -644,7 +645,15 @@ mod tests { }; assert_eq!( labels(menu), - ["Claude", "Codex", "Pi", "Muse", "Grok Build", "OpenCode"] + [ + "Claude", + "Codex", + "Pi", + "Muse", + "Grok Build", + "OpenCode", + "Copilot" + ] ); assert_eq!( menu.hover, 0, @@ -659,7 +668,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, "muse_enabled": false, "opencode_enabled": false, "harnesses":{"grok":{"enabled":false}}}"#, + r#"{"claude_enabled": false, "codex_enabled": false, "cursor_enabled": false, "pi_enabled": false, "muse_enabled": false, "opencode_enabled": false, "harnesses":{"grok":{"enabled":false},"copilot":{"enabled":false}}}"#, || { let worktree = WorktreeId("w1".into()); let mut app = App::new(); diff --git a/crates/nebula-tui/src/config.rs b/crates/nebula-tui/src/config.rs index bb84cf3b..bdb3e52a 100644 --- a/crates/nebula-tui/src/config.rs +++ b/crates/nebula-tui/src/config.rs @@ -4992,7 +4992,8 @@ mod tests { AgentKind::Pi, AgentKind::Muse, AgentKind::Grok, - AgentKind::OpenCode + AgentKind::OpenCode, + AgentKind::Copilot ], "the disabled kind drops out, order kept" ); @@ -5020,7 +5021,7 @@ mod tests { // 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,"muse_enabled":false,"opencode_enabled":false,"harnesses":{"grok":{"enabled":false}}}"#, + r#"{"claude_enabled":false,"codex_enabled":false,"cursor_enabled":false,"pi_enabled":false,"muse_enabled":false,"opencode_enabled":false,"harnesses":{"grok":{"enabled":false},"copilot":{"enabled":false}}}"#, ) .unwrap(); assert!(cfg.enabled_kinds().is_empty()); @@ -5068,6 +5069,7 @@ mod tests { (AgentKind::Muse, None), (AgentKind::Grok, None), (AgentKind::OpenCode, None), + (AgentKind::Copilot, None), ] ); @@ -5082,8 +5084,8 @@ mod tests { ) .unwrap(); let offered = cfg.offered_harnesses(); - assert_eq!(offered.len(), 8); - assert_eq!(offered[7], (AgentKind::Custom, Some("agy".into()))); + assert_eq!(offered.len(), 9); + assert_eq!(offered[8], (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))); @@ -5593,6 +5595,14 @@ mod tests { "OpenCode".to_string(), vec!["Enabled".to_string(), "Model".to_string()] ), + ( + "Copilot".to_string(), + vec![ + "Enabled".to_string(), + "Model".to_string(), + "Effort".to_string() + ] + ), ] ); @@ -5648,6 +5658,7 @@ mod tests { "Muse", "Grok Build", "OpenCode", + "Copilot", "agy" ] ); diff --git a/crates/nebula-tui/src/event_loop/tests/mod.rs b/crates/nebula-tui/src/event_loop/tests/mod.rs index bb0cec67..509f9327 100644 --- a/crates/nebula-tui/src/event_loop/tests/mod.rs +++ b/crates/nebula-tui/src/event_loop/tests/mod.rs @@ -7139,7 +7139,15 @@ fn picker_omits_a_disabled_harness() { let labels: Vec<&str> = menu.items.iter().map(|item| item.label.as_str()).collect(); assert_eq!( labels, - ["Claude", "Cursor", "Pi", "Muse", "Grok Build", "OpenCode"], + [ + "Claude", + "Cursor", + "Pi", + "Muse", + "Grok Build", + "OpenCode", + "Copilot" + ], "Codex is absent, not greyed" ); @@ -7167,7 +7175,7 @@ fn picker_omits_a_disabled_harness() { #[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, "muse_enabled": false, "opencode_enabled": false, "harnesses":{"grok":{"enabled":false}}}"#, + r#"{"claude_enabled": false, "codex_enabled": false, "cursor_enabled": false, "pi_enabled": false, "muse_enabled": false, "opencode_enabled": false, "harnesses":{"grok":{"enabled":false},"copilot":{"enabled":false}}}"#, || { let mut app = App::new(); seed_tree(&mut app); @@ -7236,6 +7244,7 @@ fn agents_tab_toggles_a_harness_and_refuses_the_last_one() { let (_, muse_row) = locate_agent("muse", HarnessField::Enabled).unwrap(); let (_, grok_row) = locate_agent("grok", HarnessField::Enabled).unwrap(); let (_, opencode_row) = locate_agent("opencode", HarnessField::Enabled).unwrap(); + let (_, copilot_row) = locate_agent("copilot", HarnessField::Enabled).unwrap(); let mut app = App::new(); let mut out = Vec::new(); open_settings_on(&mut app, tab, &mut out); @@ -7282,12 +7291,18 @@ fn agents_tab_toggles_a_harness_and_refuses_the_last_one() { press(&mut app, KeyCode::Down, KeyModifiers::NONE, &mut out); } press(&mut app, KeyCode::Enter, KeyModifiers::NONE, &mut out); + assert!(!crate::config::Config::load().opencode_enabled); + + for _ in opencode_row..copilot_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.opencode_enabled, + cfg.kind_enabled(AgentKind::Copilot), "the last harness cannot be switched off" ); - assert_eq!(cfg.enabled_kinds(), vec![AgentKind::OpenCode]); + assert_eq!(cfg.enabled_kinds(), vec![AgentKind::Copilot]); 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}"); diff --git a/crates/nebula/src/cli.rs b/crates/nebula/src/cli.rs index 283d426e..c99651a4 100644 --- a/crates/nebula/src/cli.rs +++ b/crates/nebula/src/cli.rs @@ -177,7 +177,7 @@ pub(crate) enum Command { #[arg(required = true, num_args = 1..)] task: Vec, /// Harness for the new session: claude, codex, cursor, pi, muse, - /// grok or opencode. + /// grok, opencode or copilot. /// /// Defaults to the harness this session is running. #[arg(long, value_name = "KIND", value_parser = parse_agent_kind)] diff --git a/docs/commands.md b/docs/commands.md index ab24a9a5..09c36cbb 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -70,7 +70,7 @@ nebula worktree [name] [--base ] # move the current session into a worktre # a branch name meaning origin's fetched copy — main is origin/main — # but is ignored when the branch already exists; without it the # worktree_base_branch setting applies, else origin's default) -nebula spawn [--kind ] [--worktree [--base ]] +nebula spawn [--kind ] [--worktree [--base ]] # start a new agent session beside the current 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; custom harnesses launch from diff --git a/docs/configuration.md b/docs/configuration.md index 0e7cc461..428a073d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -47,7 +47,7 @@ Sixty-five keys. **Overlay** is the SETTINGS OVERLAY tab whose row edits 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`, the Project tab's **Run command**) 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**, **Cursor**, **Pi**, **Muse**, **Grok Build** and **OpenCode** headers, so a harness's rows read `Enabled` / `Model` / +prompt**, **Claude**, **Codex**, **Cursor**, **Pi**, **Muse**, **Grok Build**, **OpenCode** and **Copilot** headers, so a harness's rows read `Enabled` / `Model` / `Effort` under its name rather than repeating it. The **Project** tab is the one tab whose rows are not nebula's but one project's: the project the grid is scoped to, named with its path on the tab's first line, and each row there reads and writes that project's own entry under `projects` @@ -101,7 +101,7 @@ behaviors that change how the tree is worked; every switch there is off by defau | `confirm_drag_move` | bool | `true` | Sessions | CONFIRM DRAG MOVE: a session card dragged onto another worktree's band or another PROJECT TAB asks before the session moves (the move restarts its CLI there, resumed on the same conversation). Off, the drop moves it at once. The MOVE PICKER (`m`) never asks. See [Keys](keys.md). | | `remember_harness` | bool | `false` | Experimental | REMEMBER HARNESS: a launch walked through the NEW SESSION PICKER (`n`), the PR SESSION picker or the QUICK PROMPT's `Tab` picker makes its harness the default the next launch starts on — the picker opens on that row and `p` launches it — and a model or effort drilled into through the submenus becomes that harness's own Model / Effort default. It writes the Agents tab's own rows (`quick_prompt_kind`, `_model`, `_effort`), so the tab always shows what the next launch will be; a pick that already is the default writes nothing, and an AGENT PRESET launch changes nothing, its harness being the preset's. Off, a pick is one session's: the NEW SESSION PICKER keeps opening on the `quick_prompt_kind` harness and the PR SESSION picker on its first row. See [Sessions](sessions.md#the-new-session-picker). | | `pr_issue_counts` | bool | `true` | — (retired) | Through 0.37, **PR & issue counts** (Settings → Experimental): off, the GRID's header dropped the `3 prs · 2 issues` beside the session count and the other projects' issues were never swept. The header always counts now — what is waiting on a repo is read off it without opening `v` or `i`, a click on either count opens that list, a count is left out until its list has landed, and a list cut off at the fetch cap counts `100+` — so this build never reads the key and no tab edits it; it is still loaded and written back as stored for an older nebula sharing the file ([Compatibility rules](#compatibility-rules)). | -| `quick_prompt_kind` | string | `"claude"` | Agents | Which AGENT KIND the QUICK PROMPT (`p`) launches: `claude`, `codex`, `cursor`, `pi`, `muse`, `grok` or `opencode`. 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. The NEW SESSION PICKER opens on it too, and with **Remember harness** on (Experimental) every picker-walked launch rewrites it. | +| `quick_prompt_kind` | string | `"claude"` | Agents | Which AGENT KIND the QUICK PROMPT (`p`) launches: `claude`, `codex`, `cursor`, `pi`, `muse`, `grok`, `opencode` or `copilot`. 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. The NEW SESSION PICKER opens on it too, and with **Remember harness** on (Experimental) every picker-walked launch rewrites it. | | `quick_prompt_focus` | bool | `false` | Agents | QUICK PROMPT FOCUS: whether a QUICK PROMPT launch enters and locks the new session's TERMINAL PANE. Off, the keys stay on the grid, and where the cursor goes is `follow_new_session`'s. On, it outranks that key: a launch that enters the new session's pane has to go there. Only the QUICK PROMPT reads it — every other launch takes the pane. | | `follow_new_session` | bool | `true` | Agents | FOLLOW NEW SESSION (**Follow new**): a QUICK PROMPT launch lands the cursor on the new session's card — the grid scrolls to keep it on screen and the pane shows it, the keys still on the grid — so a run of launches can be watched going up. It selects the card and no more: entering its terminal is `quick_prompt_focus`'s. It covers every QUICK PROMPT: `p`, `n`'s box, `Shift+P` on a card, and the boxes the ISSUES MODAL and the PULL REQUESTS MODAL open. Off, the cursor, the pane and the keys stay on the card you were on while the new session's card goes up in its band — first in it, stand-in rows first for a launch that cuts a fresh worktree — and the footer names the branch it went to (`started a session in feat`): the BACKGROUND LAUNCH's stillness, in the project on screen. A launch with no card under the cursor — the aim let go with `Esc`, an empty band, an empty grid — has nothing to keep and lands on its new card either way; so does every launch while `quick_prompt_focus` is on. Terminals (`t`) always come up in the pane. | | `quick_prompt_new_worktree` | bool | `false` | Agents | QUICK PROMPT NEW WORKTREE: whether each new QUICK PROMPT starts aimed at a fresh worktree (a random branch off the **Worktree base branch**) instead of the checkout under the grid's cursor. `Ctrl+N` in the box flips only that box; the next one starts from this again. Off by default, so `p` starts in the checkout under the cursor — the worktree whose band is selected, or the one the grid is inside — and on the project's root branch with nothing selected (`Esc` off the band). | @@ -125,7 +125,7 @@ behaviors that change how the tree is worked; every switch there is off by defau | `muse_effort` | string | `"default"` | Agents | Reserved until the `muse` CLI documents a reasoning flag. Stored, never sent. | | `opencode_model` | string | `"default"` | Agents | Default `--model` for new OpenCode sessions: a `provider/model` id passed verbatim (`opencode models` lists what your machine has credentials for). The overlay lists a few well-known ids (`opencode/big-pickle`, `anthropic/claude-sonnet-5`, …); a hand-edited one passes through. `"default"` means don't pass the flag, so OpenCode opens on its own last-picked model. There is no `opencode_effort`: OpenCode has no effort flag (reasoning is a per-model variant picked inside its TUI), so its Agents section has no Effort row. | | `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`, `pi` or `opencode` — 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, Grok Build, OpenCode), 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. | +| `harnesses` | object | `{}` | Agents | The harness registry: per-harness deltas over the compiled-in known harnesses (Claude, Codex, Cursor, Pi, Muse, Grok Build, OpenCode, Copilot), 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 — for a machine with less memory to spare — landing on a worktree boots nothing: a session forks only when your cursor lands on its row or you attach to it, one at a time; sessions already up stay until the IDLE REAPER takes them. | @@ -196,6 +196,32 @@ only `default` unless you configure `models` and `efforts` lists in that same bl `--rules` carries additional system guidance and `--resume` accepts a stored session ID, but automatic session-ID capture and managed hooks are not yet supported. Status is process-based. +### GitHub Copilot CLI + +Copilot is enabled by default and launches `copilot` (`npm i -g @github/copilot`; verified on +1.0.108). Sign in once outside nebula with `copilot` then `/login`. + +- **Status.** Copilot runs the hooks in `/.claude/settings.local.json`, the file nebula + already manages for Claude, and sends Claude-shaped payloads. Running, finished, RECENT PROMPTS, + permission prompts and `ask_user` questions are all reported, and AUTO-TITLE works. +- **Folder trust.** Copilot asks "Do you trust the files in this folder?" the first time it opens + a checkout, and loads no hooks until you answer. The session stays grey until then. Choose + "Yes, and remember this folder" to be asked once per worktree. A starting prompt is kept + across the dialog and runs once you answer. +- **Permissions.** No skip-permissions flag is passed, so Copilot asks before it edits or runs + commands and the row goes red while it waits. To run unattended, set + `"harnesses": {"copilot": {"permissions_flag": "--allow-all"}}`. The launch always carries + `--allow-tool=shell(nebula:*)`, so the AUTO-TITLE `nebula rename` runs without a prompt; + override `harnesses.copilot.launch_args` to change that. +- **Model and effort.** The model list belongs to your Copilot account, so the picker offers only + `default`. Set `harnesses.copilot.model_default`, or a `models` list, to pass `--model` + (`auto`, or any id Copilot's `/model` shows). Effort passes as `--reasoning-effort`. +- **Resume.** The session id is captured on the first prompt and a restart runs + `copilot --resume `. Copilot reopens a session in the directory it recorded, so a session + nebula moved to another worktree is not told about the move. +- **Guidance.** Copilot has no system-prompt flag, so nebula's worktree and spawn guidance is not + passed. A PR SESSION's or ISSUE SESSION's rule rides the first prompt, as for Codex. + ### 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 aba5c4e7..c43985cd 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -122,7 +122,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 when you pick **Run**. `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`, `pi`, `muse`, `grok`, `opencode`, or a custom registry program.** Creating an agent (`n`) first asks which CLI to +- **Agents boot `claude`, `codex`, `cursor-agent`, `pi`, `muse`, `grok`, `opencode`, `copilot`, or a custom registry program.** Creating an agent (`n`) first asks which CLI to run, then opens the QUICK PROMPT set to it, and the launch 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 @@ -131,7 +131,7 @@ 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) / `opencode --session ` / `grok --resume ` (nebula does not capture + dying) / `opencode --session ` / `copilot --resume ` / `grok --resume ` (nebula does not capture Grok's id yet, so there is rarely one to resume); `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; @@ -144,7 +144,7 @@ before the spawn, and an id listed as a `background` session opens as `claude attach ` instead — the live conversation, which detaching (Ctrl+Z) or archiving the row leaves running. A refused resume the job-dir look missed is asked about the same way and re-opened attached. An AGENT created from the PULL REQUESTS MODAL also receives the PR URL and a PR-only - work rule — Claude and Pi through `--append-system-prompt` and Grok Build through `--rules` on every spawn, Codex, Cursor, Muse and OpenCode as the first prompt of + work rule — Claude and Pi through `--append-system-prompt` and Grok Build through `--rules` on every spawn, Codex, Cursor, Muse, OpenCode and Copilot 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 @@ -294,7 +294,7 @@ missing session instead of failing, so a lost Pi session is not detected.) - **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|muse|grok|opencode` + agent beside it — same worktree, same harness, model and effort unless `--kind claude|codex|cursor|pi|muse|grok|opencode|copilot` names another — opening on that task as its first prompt, so it is working before you look. The new card appears on the grid on its own (default name, so it titles itself), and the session you asked from is untouched: no restart, no focus change. Ask for that separate session "on branch diff --git a/docs/sessions.md b/docs/sessions.md index 3c1a6cdb..cad43651 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -7,7 +7,7 @@ Everything that can start an AGENT, and what each launch path does differently. ## The NEW SESSION PICKER Press `n` on the grid. A menu asks what to -run — **Claude**, **Codex**, **Cursor**, **Pi**, **Muse**, **Grok Build**, or **OpenCode** (a plain shell is `t` — see [Keys](keys.md)); a CLI you never use can be +run — **Claude**, **Codex**, **Cursor**, **Pi**, **Muse**, **Grok Build**, **OpenCode**, or **Copilot** (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 @@ -622,7 +622,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`, Grok Build through `--rules`, and Codex, Cursor, Muse and OpenCode as the opening +branch — Claude and Pi receive it through `--append-system-prompt`, Grok Build through `--rules`, and Codex, Cursor, Muse, OpenCode and Copilot 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