diff --git a/crates/nebula-core/src/protocol.rs b/crates/nebula-core/src/protocol.rs index 8348fea6..7caf5b2f 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 = 53; /// Oldest IPC protocol this build can safely talk to. /// @@ -25,6 +25,11 @@ pub const MIN_COMPATIBLE_PROTOCOL: u32 = 49; /// without the check. pub const CHECK_WORKTREE_PROTOCOL: u32 = 51; +/// First protocol whose clients read `WorktreeCheck::locked`. The daemon +/// leaves it off the check it sends an older client, whose reply is then +/// byte for byte what it always was. +pub const WORKTREE_LOCK_PROTOCOL: u32 = 53; + pub fn protocol_ranges_overlap( local_min: u32, local_current: u32, @@ -647,6 +652,15 @@ pub struct WorktreeCheck { pub detached: bool, /// Why git could not be asked, when it could not. pub git_error: Option, + /// The checkout is locked (`git worktree lock`): the reason it was + /// locked with, empty when none was given. Whoever locked it — a + /// person, an agent, a CI tool — asked git to keep it, and git's own + /// `worktree remove` refuses it; a delete unlocks it first, so the + /// confirm says so. Kept last and left off the wire when None: an + /// older client decodes the shorter check it always did, and the + /// daemon clears it for one (`WORKTREE_LOCK_PROTOCOL`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub locked: Option, } /// One process `WorktreeCheck` found working inside a checkout. @@ -666,6 +680,7 @@ impl WorktreeCheck { || self.changes > 0 || self.ahead > 0 || self.git_error.is_some() + || self.locked.is_some() } } @@ -862,4 +877,56 @@ mod tests { assert!(protocol_compatible_with(PROTOCOL_VERSION + 1)); assert!(!protocol_compatible_with(MIN_COMPATIBLE_PROTOCOL - 1)); } + + /// A check with no lock is the bytes a client older than + /// `WORKTREE_LOCK_PROTOCOL` always read; one with a lock reaches a new + /// client whole. + #[test] + fn a_checks_lock_costs_an_older_client_nothing() { + /// `WorktreeCheck` as it was before `locked`. + #[derive(Serialize, Deserialize)] + struct Before { + processes: Vec, + process_error: Option, + changes: u32, + newest_change_ms: Option, + ahead: u32, + base: Option, + detached: bool, + git_error: Option, + } + let unlocked = WorktreeCheck { + changes: 2, + base: Some("origin/main".into()), + ..WorktreeCheck::default() + }; + let before = Before { + processes: Vec::new(), + process_error: None, + changes: 2, + newest_change_ms: None, + ahead: 0, + base: Some("origin/main".into()), + detached: false, + git_error: None, + }; + assert_eq!( + rmp_serde::to_vec(&unlocked).unwrap(), + rmp_serde::to_vec(&before).unwrap() + ); + let older: WorktreeCheck = + rmp_serde::from_slice(&rmp_serde::to_vec(&before).unwrap()).unwrap(); + assert_eq!( + older, unlocked, + "a newer client reads an older daemon's check" + ); + + let locked = WorktreeCheck { + locked: Some("kept".into()), + ..unlocked + }; + let back: WorktreeCheck = + rmp_serde::from_slice(&rmp_serde::to_vec(&locked).unwrap()).unwrap(); + assert_eq!(back, locked); + } } diff --git a/crates/nebula-daemon/src/git.rs b/crates/nebula-daemon/src/git.rs index b380a60f..b172a3c1 100644 --- a/crates/nebula-daemon/src/git.rs +++ b/crates/nebula-daemon/src/git.rs @@ -170,6 +170,78 @@ fn parse_worktree_list(out: &str) -> Vec { entries } +/// The lock git keeps on `worktree`, a checkout of `repo`: the reason it +/// was locked with (empty when none was given), or None when it is not +/// locked. Anyone can lock a checkout — `git worktree lock --reason …` by +/// hand, an agent marking the one it works in, a tool keeping one it +/// reuses — and git's own `worktree remove` and `prune` then leave it be. +pub async fn worktree_lock(repo: &Path, worktree: &Path) -> Result> { + let out = git(repo, &["worktree", "list", "--porcelain"]).await?; + let real = std::fs::canonicalize(worktree).ok(); + Ok(lock_in_list(&out, |listed| { + listed == worktree || real.is_some() && std::fs::canonicalize(listed).ok() == real + })) +} + +/// The pure half of [`worktree_lock`]: in `git worktree list --porcelain` +/// output, the `locked` line of the stanza whose path `is_target` picks. +/// Git writes `locked` alone when no reason was given, and C-quotes a +/// reason it cannot print as is (a newline, a quote). +fn lock_in_list(out: &str, is_target: impl Fn(&Path) -> bool) -> Option { + let mut in_target = false; + for line in out.lines() { + if let Some(listed) = line.strip_prefix("worktree ") { + in_target = is_target(Path::new(listed)); + } else if in_target && line == "locked" { + return Some(String::new()); + } else if let Some(reason) = line.strip_prefix("locked ").filter(|_| in_target) { + return Some(unquote_c_style(reason)); + } + } + None +} + +/// Undo git's C-style quoting (`"a \"b\"\n\303\251"`); text git left +/// unquoted comes back as it is. +fn unquote_c_style(text: &str) -> String { + let Some(inner) = text.strip_prefix('"').and_then(|t| t.strip_suffix('"')) else { + return text.to_string(); + }; + let mut bytes = Vec::with_capacity(inner.len()); + let mut chars = inner.bytes().peekable(); + while let Some(b) = chars.next() { + if b != b'\\' { + bytes.push(b); + continue; + } + match chars.next() { + Some(b'n') => bytes.push(b'\n'), + Some(b't') => bytes.push(b'\t'), + Some(b'r') => bytes.push(b'\r'), + Some(b'a') => bytes.push(0x07), + Some(b'b') => bytes.push(0x08), + Some(b'f') => bytes.push(0x0c), + Some(b'v') => bytes.push(0x0b), + Some(d @ b'0'..=b'7') => { + let mut value = u32::from(d - b'0'); + for _ in 0..2 { + match chars.peek() { + Some(&o @ b'0'..=b'7') => { + value = value * 8 + u32::from(o - b'0'); + chars.next(); + } + _ => break, + } + } + bytes.push(u8::try_from(value).unwrap_or(b'?')); + } + Some(other) => bytes.push(other), + None => bytes.push(b'\\'), + } + } + String::from_utf8_lossy(&bytes).into_owned() +} + /// The branch a paused rebase in `checkout` is replaying, if there is one — /// read from the same state file `git status` uses to say "rebasing branch /// X" while `git worktree list` calls the checkout detached. `None` for a @@ -736,10 +808,13 @@ pub async fn remove_worktree(repo: &Path, worktree_path: &Path, force: bool) -> let _ = git(repo, &["worktree", "prune"]).await; Ok(()) } - // Locked by a session that ran `git worktree lock` (Claude Code locks - // its worktree and a killed session never unlocks). The caller has - // already killed this worktree's sessions, so the lock is stale — - // unlock and retry rather than surfacing git's refusal. + // Locked (`git worktree lock`): often by an agent that locks the + // checkout it works in and never unlocks when killed, but anyone + // can lock one to keep it. A delete the person asked for was + // confirmed with the lock and its reason in front of them (the + // DELETE CHECK names it, to a client that reads it); auto-cleanup + // never takes a locked checkout. So unlock and retry rather than + // surfacing git's refusal. Err(e) if e.to_string().contains("locked working tree") => { git(repo, &["worktree", "unlock", &path_str]).await?; git(repo, &args).await?; @@ -1450,6 +1525,58 @@ mod tests { remove_worktree(&repo, &wt, false).await.unwrap(); } + #[test] + fn lock_in_list_reads_the_target_stanzas_lock_and_reason() { + let out = "worktree /r\nHEAD 1\nbranch refs/heads/main\n\n\ + worktree /r/a\nHEAD 2\nbranch refs/heads/a\nlocked\n\n\ + worktree /r/b\nHEAD 3\ndetached\nlocked merge queue: reused by every push\n\n\ + worktree /r/c\nHEAD 4\nbranch refs/heads/c\nlocked \"two\\nlines \\\"q\\\" caf\\303\\251\"\n\n\ + worktree /r/d\nHEAD 5\nbranch refs/heads/d\n"; + let lock = |p: &str| lock_in_list(out, |listed| listed == Path::new(p)); + assert_eq!(lock("/r"), None, "the main checkout is not locked"); + assert_eq!(lock("/r/a").as_deref(), Some(""), "locked, no reason"); + assert_eq!( + lock("/r/b").as_deref(), + Some("merge queue: reused by every push") + ); + assert_eq!(lock("/r/c").as_deref(), Some("two\nlines \"q\" café")); + assert_eq!(lock("/r/d"), None, "another stanza's lock is not its"); + assert_eq!(lock("/r/x"), None, "not listed"); + } + + #[tokio::test] + async fn worktree_lock_reads_what_git_worktree_lock_wrote() { + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path().join("repo"); + std::fs::create_dir(&repo).unwrap(); + init_repo(&repo).await; + let wt = add_worktree(&repo, "feature", None).await.unwrap(); + let wt_str = wt.to_string_lossy().into_owned(); + assert_eq!(worktree_lock(&repo, &wt).await.unwrap(), None); + git(&repo, &["worktree", "lock", &wt_str]).await.unwrap(); + assert_eq!( + worktree_lock(&repo, &wt).await.unwrap().as_deref(), + Some("") + ); + git(&repo, &["worktree", "unlock", &wt_str]).await.unwrap(); + git( + &repo, + &[ + "worktree", + "lock", + "--reason", + "kept for the nightly build", + &wt_str, + ], + ) + .await + .unwrap(); + assert_eq!( + worktree_lock(&repo, &wt).await.unwrap().as_deref(), + Some("kept for the nightly build") + ); + } + #[tokio::test] async fn remove_worktree_unlocks_session_locked_checkout() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/nebula-daemon/src/registry.rs b/crates/nebula-daemon/src/registry.rs index c8a4fb10..4c8a2759 100644 --- a/crates/nebula-daemon/src/registry.rs +++ b/crates/nebula-daemon/src/registry.rs @@ -4586,6 +4586,52 @@ mod tests { assert!(!wt.exists()); } + /// A checkout someone locked is theirs to keep: the cleanup leaves it + /// and says why, whoever locked it. + #[tokio::test] + async fn merged_cleanup_skips_a_locked_checkout() { + let tmp = tempfile::tempdir().unwrap(); + let root = std::fs::canonicalize(tmp.path()).unwrap(); + let repo = init_repo(&root); + let wt = root.join("repo-worktrees").join("feat"); + let wt_str = wt.to_string_lossy().into_owned(); + git_in(&repo, &["worktree", "add", &wt_str, "-b", "feat"]); + git_in( + &repo, + &[ + "worktree", + "lock", + "--reason", + "reused by the release job", + &wt_str, + ], + ); + let daemon = test_daemon(); + project_at(&daemon, &repo); + seed_worktree(&daemon, "p", "feat", &wt_str, false); + let id = WorktreeId("feat".into()); + let head = git::head_sha(&wt).await.unwrap(); + let url = "https://github.com/o/r/pull/1"; + + let err = daemon + .cleanup_merged_worktree(&id, "feat", 1, url, &head) + .await + .expect_err("a locked checkout is kept"); + assert!( + err.to_string() + .contains("checkout is locked (reused by the release job)"), + "{err:#}" + ); + assert!(wt.exists()); + + git_in(&repo, &["worktree", "unlock", &wt_str]); + daemon + .cleanup_merged_worktree(&id, "feat", 1, url, &head) + .await + .unwrap(); + assert!(!wt.exists()); + } + fn git_in(repo: &Path, args: &[&str]) { let out = std::process::Command::new("git") .arg("-C") diff --git a/crates/nebula-daemon/src/registry/projects.rs b/crates/nebula-daemon/src/registry/projects.rs index d69380c6..95591d5a 100644 --- a/crates/nebula-daemon/src/registry/projects.rs +++ b/crates/nebula-daemon/src/registry/projects.rs @@ -625,6 +625,16 @@ impl Daemon { if !status.trim().is_empty() { bail!("auto-cleanup skipped {branch}: checkout has uncommitted or untracked changes"); } + // Someone asked git to keep it; only a person, at the confirm, may + // overrule that. + if let Some(reason) = git::worktree_lock(&project.repo_path, &worktree.path).await? { + let why = if reason.is_empty() { + String::new() + } else { + format!(" ({reason})") + }; + bail!("auto-cleanup skipped {branch}: checkout is locked{why}"); + } let (_, _, agents, terminals) = self.store_blocking(|store| store.load_tree()).await?; if let Some(agent) = agents.iter().find(|agent| { diff --git a/crates/nebula-daemon/src/server.rs b/crates/nebula-daemon/src/server.rs index 9ccfb177..d7a70088 100644 --- a/crates/nebula-daemon/src/server.rs +++ b/crates/nebula-daemon/src/server.rs @@ -87,6 +87,9 @@ struct ClientConnection { attached: HashMap>, subscription: Option>, handshaken: bool, + /// The protocol the client said it speaks in its `Hello`: a reply + /// carrying something newer than that is cut down to what it reads. + client_protocol: u32, /// The client process, when the socket's credentials name it. peer_pid: Option, } @@ -99,6 +102,7 @@ impl ClientConnection { attached: HashMap::new(), subscription: None, handshaken: false, + client_protocol: 0, peer_pid: None, } } @@ -649,6 +653,7 @@ impl ClientConnection { async fn handle_hello(&mut self, protocol_version: u32) -> bool { self.handshaken = nebula_core::protocol_compatible_with(protocol_version); + self.client_protocol = protocol_version; let reply = if self.handshaken { ServerEvent::HelloOk { protocol_version: PROTOCOL_VERSION, @@ -911,9 +916,15 @@ impl ClientConnection { let daemon = self.daemon.clone(); let out_tx = self.out_tx.clone(); let client = self.peer_pid; + let reads_lock = self.client_protocol >= nebula_core::WORKTREE_LOCK_PROTOCOL; tokio::spawn(async move { let ev = match daemon.check_worktree(&id, client).await { - Ok(check) => ServerEvent::WorktreeChecked { req_id, id, check }, + Ok(mut check) => { + if !reads_lock { + check.locked = None; + } + ServerEvent::WorktreeChecked { req_id, id, check } + } Err(e) => ServerEvent::Error { req_id: Some(req_id), message: format!("{e:#}"), diff --git a/crates/nebula-daemon/src/worktree_check.rs b/crates/nebula-daemon/src/worktree_check.rs index ece3fd6a..78df8885 100644 --- a/crates/nebula-daemon/src/worktree_check.rs +++ b/crates/nebula-daemon/src/worktree_check.rs @@ -9,7 +9,8 @@ //! each cheap and each best effort, and each saying so when it could not //! look: which processes have their working directory inside the checkout, //! what `git status` reports there and how recently it was written, and -//! which commits HEAD has that its base lacks. +//! which commits HEAD has that its base lacks. And one thing a person or +//! tool may have said outright: that the checkout is locked, and why. use crate::git; use anyhow::{Context, Result}; @@ -81,6 +82,10 @@ async fn git_half(repo: &Path, worktree: &Path, configured_base: Option) &mut check, ); } + match git::worktree_lock(repo, worktree).await { + Ok(lock) => check.locked = lock, + Err(e) => note(e, &mut check), + } check } diff --git a/crates/nebula-tui/src/delete_check.rs b/crates/nebula-tui/src/delete_check.rs index e8c466b2..b7666ad0 100644 --- a/crates/nebula-tui/src/delete_check.rs +++ b/crates/nebula-tui/src/delete_check.rs @@ -326,6 +326,17 @@ fn bulk_lines(states: &[(String, &CheckState)], now_ms: i64) -> Vec { /// A finished check's findings, one sentence each. fn findings(check: &WorktreeCheck, now_ms: i64) -> Vec { let mut out = Vec::new(); + // First: the one finding someone stated on purpose. + match check.locked.as_deref() { + Some("") => { + out.push("locked (git worktree lock, no reason given) — deleting unlocks it".into()) + } + Some(reason) => out.push(format!( + "locked: {} — deleting unlocks it", + one_line(reason) + )), + None => {} + } if !check.processes.is_empty() { let n = check.processes.len(); out.push(format!( @@ -367,6 +378,11 @@ fn findings(check: &WorktreeCheck, now_ms: i64) -> Vec { /// A finished check's findings as a bulk delete's line lists them. fn short_findings(check: &WorktreeCheck, now_ms: i64) -> Vec { let mut out = Vec::new(); + match check.locked.as_deref() { + Some("") => out.push("locked".into()), + Some(reason) => out.push(format!("locked: {}", one_line(reason))), + None => {} + } if !check.processes.is_empty() { out.push(format!("running {}", process_names(check))); } @@ -390,6 +406,11 @@ fn short_findings(check: &WorktreeCheck, now_ms: i64) -> Vec { out } +/// A lock reason as one line of a dialog: git keeps a reason's newlines. +fn one_line(reason: &str) -> String { + reason.split_whitespace().collect::>().join(" ") +} + fn process_names(check: &WorktreeCheck) -> String { let mut names: Vec = check .processes @@ -461,6 +482,39 @@ mod tests { ); } + #[test] + fn a_locked_checkout_leads_with_its_lock_and_reason() { + let check = WorktreeCheck { + locked: Some("merge queue:\nreused by every push".into()), + changes: 1, + ..WorktreeCheck::default() + }; + let text: Vec = single_lines("probe", &done(check.clone()), 0) + .into_iter() + .inspect(|l| assert!(l.warn)) + .map(|l| l.text) + .collect(); + assert_eq!( + text, + [ + "⚠ locked: merge queue: reused by every push — deleting unlocks it", + "⚠ 1 uncommitted change", + ] + ); + assert_eq!( + short_findings(&check, 0)[0], + "locked: merge queue: reused by every push" + ); + + let bare = WorktreeCheck { + locked: Some(String::new()), + ..WorktreeCheck::default() + }; + assert!(bare.has_findings(), "a lock alone is a finding"); + let text = &single_lines("probe", &done(bare), 0)[0].text; + assert!(text.contains("no reason given"), "{text}"); + } + #[test] fn a_detached_head_warns_that_only_the_reflog_keeps_its_commits() { let check = WorktreeCheck { diff --git a/docs/configuration.md b/docs/configuration.md index 72e470f6..f4932f0d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -70,7 +70,7 @@ behaviors that change how the tree is worked; every switch there is off by defau | `feedback_sound` | string | `"Sosumi"` | Sessions | The FEEDBACK SOUND rung when a turn stops at NEEDS FEEDBACK — a permission prompt or a question — with the same values and fallbacks as `done_sound`, and a different default so red and green sound different from the next room. It never rings for the session whose pane you are locked into typing at while the terminal window has focus: that prompt is already under your hands. The one switch for the DESKTOP NOTIFICATION too: while the terminal window is in the background (from the focus reports nebula asks the terminal for — tmux needs `focus-events on`), each session that goes red is also named in a desktop notification (`osascript` on macOS, `notify-send` on Linux; never over `nebula ssh`, where the desktop is the wrong machine's; a notifier that is missing or fails is a debug line, not an error). `off` silences the sound and the notification together. | | `preset_text` | string | `"prefix"` | Sessions | PRESET TEXT: which side of the task a new AGENT PRESET's text goes — `prefix` (one box, sent before the task), `postfix` (one box, sent after it) or `prefix & postfix` (both, the form every preset had through 0.32). The PRESET EDITOR opens a new preset with the box(es) named here, and its **Text** row changes one preset — a side the row leaves out saves blank; editing a stored preset also shows any side that already holds text, so nothing saved is ever hidden. Prefix alone by default: the framing most people reach for, and one box to fill. A hand-edited `both` reads as `prefix & postfix`; anything else as the default. See [Sessions](sessions.md#agent-presets). | | `delete_empty_worktree` | bool | `false` | Sessions | DELETE EMPTIED WORKTREE: what the delete of a linked WORKTREE's last live card asks — the last session's `d`, the last terminal's close, or a `D` that takes them all. Off, the default, that card's own CONFIRM DIALOG carries the question too, before anything is deleted (`Delete agent 'x'? Its session and history go away.` then `Nothing else is left in worktree 'feature': delete it from disk too?`), with three answers: `Enter` or `y` deletes the card and then the worktree, the same forced delete the band's own `d` runs; `n` deletes the card and keeps the empty checkout; `Esc` cancels and keeps the card alive. On, the question is not asked: the dialog is the card's ordinary two-way confirm, its message saying the worktree goes with it, and `Enter` deletes both — unless archived sessions are still filed under the checkout, whose history the delete would take: those always get the three-way question, which says how many go with it. The ROOT WORKTREE is never offered either way, and archiving a session (`a`) never counts as emptying: an archived card is still filed under its checkout. With `show_all_worktrees` on, off asks nothing — the emptied worktree keeps its band — and on still deletes the worktree with its last card. See [Sessions](sessions.md#the-grid). | -| `auto_cleanup_merged` | bool | `false` | Sessions | AUTO-CLEAN MERGED WORKTREES: off by default. On, when a linked worktree's branch PR is safely detected as merged, nebula asks the DAEMON to archive idle sessions in that checkout and remove the worktree from disk with `git worktree remove` (not forced). Safety gates: never the ROOT WORKTREE, never the repository default branch, never a branch that is the base of an open PR, only a MERGED PR whose head branch matches and whose head SHA is still the checkout's HEAD, never a checkout with uncommitted or untracked files, never a checkout with a running terminal or a session mid-turn. A skipped cleanup flashes why; a cleanup flashes `cleaned up : PR # merged`. | +| `auto_cleanup_merged` | bool | `false` | Sessions | AUTO-CLEAN MERGED WORKTREES: off by default. On, when a linked worktree's branch PR is safely detected as merged, nebula asks the DAEMON to archive idle sessions in that checkout and remove the worktree from disk with `git worktree remove` (not forced). Safety gates: never the ROOT WORKTREE, never the repository default branch, never a branch that is the base of an open PR, only a MERGED PR whose head branch matches and whose head SHA is still the checkout's HEAD, never a checkout with uncommitted or untracked files, never a locked checkout (`git worktree lock`), never a checkout with a running terminal or a session mid-turn. A skipped cleanup flashes why; a cleanup flashes `cleaned up : PR # merged`. | | `show_all_worktrees` | bool | `true` | Sessions | SHOW ALL WORKTREES: every checkout of the project gets a BAND on the grid, one with nothing running in it too. It is on by default. Off, the grid is only what is running: a checkout with no session or terminal has no band. On, such a checkout is an EMPTY BAND — its rule over one line, `nothing running · p: new session · t: terminal · d: delete worktree` — which `j`/`k` walk onto, where `p`/`n`/`t` start work, and where `d` (or **Delete worktree** in its right-click menu) deletes the worktree behind its own confirm (`Delete worktree 'feature' from disk?`); the ROOT WORKTREE's band has no `d`. On also means deleting a worktree's last card never asks about the worktree, so the emptied band stays until its own `d` — unless `delete_empty_worktree` is on, which still deletes the worktree with its last card. The ARCHIVED VIEW never shows an empty band. See [Sessions](sessions.md#the-grid). | | `appearance` | string | `"auto"` | Appearance | APPEARANCE: the dark or the light palette. `auto` asks the terminal for its background color at startup (OSC 11, with `COLORFGBG` as the fallback for a terminal that doesn't answer) and draws the light palette on a light background, the dark one otherwise; `dark` and `light` force one. In the light palette every THEME keeps its hues at a weight that reads on white, the FOCUSED PANEL TINT and the card washes mix toward white instead of black, and `black_background` paints white. Anything off the list reads as `auto`. | | `theme` | string | `"default"` | Appearance | The THEME: `default`, `ocean`, `forest`, `rose`, `amber`, `lavender`, `coral`, `slate`, `sand`, `mono`. An unknown name falls back to `default`. | diff --git a/docs/keys.md b/docs/keys.md index cea64c90..214fe9b0 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -43,7 +43,7 @@ The grid isn't the only view. With a session selected: | New session picker (any row) | `?` | open Settings → Agents parked on the highlighted harness's section, where its Enabled switch and default model and effort live (`s` does the same from a pull request row's right-click menu; in the pickers it is a filter letter) | | The grid (a cloud card) | `Enter`, right-click | `Enter` — or a click on the link in the CLOUD SESSION PANEL — opens the session's page in the browser; the right-click 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 | | The grid (a card) | `Space` | **follow-up prompt.** A small modal opens over the grid and what you type there goes to the agent already running in the selected session as its next turn: `Enter` sends it, `Shift+Enter` / `Option+Enter` / `Ctrl+J` break a line, `Esc` closes without sending. The right-click menu's **Follow-up prompt** row does the same. Only a live local agent has one: an archived session, a Claude Cloud card (whose right-click menu queues a message instead) and a terminal have nothing to follow up on. A session whose CLI is not up is booted first and the box kept as it is, so nothing is typed into a process that is still starting — press `Enter` again once it is. Rebindable as `follow_up` | -| The grid | `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 something — and `a` and the row menu's Archive ask first, in the CONFIRM DIALOG `d` goes behind (`Enter` or `y` archives, `Esc` or `n` keeps the session), so typing aimed at an agent that lands on the grid archives nothing; the dialog says what makes saying yes cheap: `u` brings the session back. **Confirm on archive** (Settings → Sessions, on by default) off archives at once. In the nested layout, `d` and `a` on a thread's root that has children take the whole thread: `d` deletes every prompt and terminal nested under it, `a` archives every prompt and closes its terminals — each behind a confirm that counts and lists them, `a`'s asking even with the setting off; the root checkout's thread included, and a project left with nothing is the welcome. `d` on the last live card of a linked worktree asks about the checkout in the same confirm, before anything is deleted — `Nothing else is left in worktree 'feature': delete it from disk too?` — with three answers: `y` deletes the card and then the checkout as `d` on its band would, `n` deletes the card and keeps the empty checkout, `Esc` keeps the card alive; **Delete emptied worktree** (Settings → Sessions, off by default) skips that question and `Enter` deletes both, except when archived sessions are still filed under the checkout. With **Show all worktrees** on (Settings → Sessions, off by default) the question is never asked: the card goes alone, its checkout stays as an EMPTY BAND, and `d` on that band — a checkout with nothing running — asks to delete the worktree itself (`Delete worktree 'feature' from disk?`), as **Delete worktree** in the band's right-click menu does. Every confirm that deletes a worktree lists what the DELETE CHECK found in it — processes working there from outside its sessions, uncommitted changes and how recently they were written, commits its base lacks — and `Enter` waits for that check (up to ten seconds) before it deletes anything. `Shift+R` refreshes the pull requests (above). **A held `a` opens one dialog** and archives nothing by itself: its repeats land on the dialog, where `a` is nothing — with the confirm off, it archives one card, as `u` unarchives one. **A held `u` unarchives one card**, however long it is held: on a terminal with the kitty keyboard protocol (Ghostty, kitty, WezTerm, foot) nebula watches the key until it comes up, swallowing the repeats the terminal sends meanwhile, and the next card needs the key pressed again; a terminal without it (Terminal.app, `nebula browser`, tmux) reports a held key as one press after another, and there a long hold still walks the row | +| The grid | `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 something — and `a` and the row menu's Archive ask first, in the CONFIRM DIALOG `d` goes behind (`Enter` or `y` archives, `Esc` or `n` keeps the session), so typing aimed at an agent that lands on the grid archives nothing; the dialog says what makes saying yes cheap: `u` brings the session back. **Confirm on archive** (Settings → Sessions, on by default) off archives at once. In the nested layout, `d` and `a` on a thread's root that has children take the whole thread: `d` deletes every prompt and terminal nested under it, `a` archives every prompt and closes its terminals — each behind a confirm that counts and lists them, `a`'s asking even with the setting off; the root checkout's thread included, and a project left with nothing is the welcome. `d` on the last live card of a linked worktree asks about the checkout in the same confirm, before anything is deleted — `Nothing else is left in worktree 'feature': delete it from disk too?` — with three answers: `y` deletes the card and then the checkout as `d` on its band would, `n` deletes the card and keeps the empty checkout, `Esc` keeps the card alive; **Delete emptied worktree** (Settings → Sessions, off by default) skips that question and `Enter` deletes both, except when archived sessions are still filed under the checkout. With **Show all worktrees** on (Settings → Sessions, off by default) the question is never asked: the card goes alone, its checkout stays as an EMPTY BAND, and `d` on that band — a checkout with nothing running — asks to delete the worktree itself (`Delete worktree 'feature' from disk?`), as **Delete worktree** in the band's right-click menu does. Every confirm that deletes a worktree lists what the DELETE CHECK found in it — a `git worktree lock` and its reason, processes working there from outside its sessions, uncommitted changes and how recently they were written, commits its base lacks — and `Enter` waits for that check (up to ten seconds) before it deletes anything. `Shift+R` refreshes the pull requests (above). **A held `a` opens one dialog** and archives nothing by itself: its repeats land on the dialog, where `a` is nothing — with the confirm off, it archives one card, as `u` unarchives one. **A held `u` unarchives one card**, however long it is held: on a terminal with the kitty keyboard protocol (Ghostty, kitty, WezTerm, foot) nebula watches the key until it comes up, swallowing the repeats the terminal sends meanwhile, and the next card needs the key pressed again; a terminal without it (Terminal.app, `nebula browser`, tmux) reports a held key as one press after another, and there a long hold still walks the row | | The grid (a card) | `e` | agent presets: saved launch definitions (harness, model, effort, text on the side of the task the form's **Text** row names — a prefix, a postfix or both; a new preset starts on `prefix` alone unless **Preset text** in Settings → Sessions says otherwise — and whether to ask for a task). The launch lands in the checkout under the cursor — or, with `Tab` (or a click on the list's bottom `[ ] new worktree` row), in a fresh worktree of the project cut first, the row turning into a green NEW WORKTREE chip naming the branch; `Tab` again flips back. The same toggle is on the list wherever it opens: in the ISSUES MODAL it flips between the root checkout and a fresh `issue-15-` worktree, and a PR SESSION's row just names the pull request's own checkout, there being nothing to flip. `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. Type to find a preset by name — letters narrow the rows to the fuzzy matches, the cursor on the best, the query in the title (`Agent presets ⌕ rev`); `↑`/`↓` (or `Ctrl+n`/`Ctrl+p`) move, `Backspace` widens, and `Esc` clears the letters before it closes. Because letters type, the verbs are chords: `Ctrl+a` / `Ctrl+e` / `Ctrl+d` create, edit, delete (`Delete` deletes too). In the PULL REQUESTS MODAL, `Shift+Tab` on a pull request is the same picker, launching the preset as a PR SESSION on it | | The grid | `p` | quick prompt: a wrapped, multi-row task box (`Shift+Enter`, `Option+Enter` or `Ctrl+J` inserts a line and `↑`/`↓` walk the rows as they wrap, as in Claude Code's own prompt — the keys for getting around a long prompt are under *Any multi-row box* below; `Esc` closes the box, `Enter` launches) that starts a new agent in the checkout under the cursor — the worktree whose band the cursor is on — with what you typed as its first prompt — no picker. Which CLI it launches is the `Agent` row under **Quick prompt** in Settings → Agents, run with that harness's own default model and effort; the session titles itself from the prompt. For one launch only, `Tab` picks a different harness (`→` drills into its model and effort, same as the new-session picker) and `Shift+Tab` picks one of your saved agent presets — adopting its harness, model, effort and prefix/postfix wrapping; sent empty with a preset on, the box launches on the prefix and postfix alone, and a `skip`-task preset picked over an empty box launches right away. Either picker hands the box back with your text intact, on `Esc` too. The launch lands the cursor on the new session's card as it goes up in its band, the grid scrolling to it (shown in the pane, the keys still on the grid) — turn off `Follow new` under **Quick prompt** in Settings → Agents to stay out of its way instead, the cursor and the pane staying on the card you were on and the footer naming the branch it went to, or turn on `Focus` there to drop straight into its terminal. The box launches in the checkout under the cursor by default — the worktree whose band the cursor is on, open or collapsed, the new session starting beside the ones already running there (more work in one session's own conversation is its follow-up, `Space`) — and on the project's root branch once `Esc` has let the aim go; turn on `New worktree` under **Quick prompt** in Settings → Agents to have every new box cut a fresh worktree first — a random branch off the fetched `origin/HEAD`, or the **Worktree base branch** setting — its band going up beside the rest, the cursor moving onto it unless `Follow new` is off. `Ctrl+N` inside the box flips between the two for this one launch only — the next box starts from the setting again: the row under the title says which — a quiet `worktree: main` with `[ ] new worktree` unticked, or a filled `NEW WORKTREE` chip naming the branch to be cut with `[✓] new worktree` ticked and the whole frame turned green — and the text and caret stay put across the flip. `Enter` on the box empty starts the session all the same — the harness, model and effort the title names, with no first prompt: you type it in the CLI (a Claude Cloud box, which needs its task, closes instead). Closing the box does not throw the prompt away: `Esc`, a click outside it and `^q` park what you typed, and the next `p` opens on it — aimed the same way, the box comes back whole, the `Tab` harness or `Shift+Tab` preset you had picked included; aimed somewhere else, your text comes back into that box's own launch. One draft is kept, in memory only, and the box that takes it back is the last place it can be lost from: clear it there and close it and it is gone | | The grid | `Shift+D` | delete every session listed for the checkout under the cursor (the confirm lists the casualties) | diff --git a/docs/sessions.md b/docs/sessions.md index 3c1a6cdb..0b7af652 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -320,7 +320,9 @@ is one key from there: `p` opens the QUICK PROMPT, focused, so the first thing y worktree with its last card. "Nothing running" counts nebula's own sessions only: an agent whose session lives in another checkout can be working in this one through absolute paths. So every confirm that would take a worktree off disk first runs the **DELETE CHECK** and lists - what it finds under the question — processes whose working directory is inside the checkout + what it finds under the question — first, when the checkout is locked (`git worktree lock`, + by hand, by an agent or by a tool that reuses it), the lock and the reason it was locked with, + since deleting unlocks it; then processes whose working directory is inside the checkout (other than its own sessions, which the confirm already counts), uncommitted changes and how recently the newest was written, and commits its base lacks (or, on a detached HEAD, commits no branch holds). `Enter` waits for the check — up to ten seconds — so the delete is never @@ -372,8 +374,8 @@ is one key from there: `p` opens the QUICK PROMPT, focused, so the first thing y Turn on **Auto-clean merged worktrees** in Settings → Sessions (`auto_cleanup_merged`) to have nebula remove linked checkouts after their PR lands. It is off by default and guarded: the ROOT WORKTREE, the repo's default branch, branches used as the base of open PRs, dirty checkouts, - checkouts with untracked files, moved heads, running terminals and sessions mid-turn are skipped - with a short notice instead. + checkouts with untracked files, locked checkouts, moved heads, running terminals and sessions + mid-turn are skipped with a short notice instead. - **Walking it.** `j` and `k` walk the bands — the rule of the band under the cursor takes the accent and its branch goes bold, its remembered card (the session it was last left on, else its first) is raised out of the row — a heavy accent frame (`┏━┓`, a weight no status frame takes) over