diff --git a/crates/nebula-core/src/protocol.rs b/crates/nebula-core/src/protocol.rs index 8348fea6..fa6f0467 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. /// @@ -25,6 +25,10 @@ pub const MIN_COMPATIBLE_PROTOCOL: u32 = 49; /// without the check. pub const CHECK_WORKTREE_PROTOCOL: u32 = 51; +/// First protocol whose daemon answers `ClientRequest::SleepAgent`. An +/// older daemon is never sent one; the client says it can't sleep there. +pub const SLEEP_AGENT_PROTOCOL: u32 = 52; + pub fn protocol_ranges_overlap( local_min: u32, local_current: u32, @@ -413,6 +417,14 @@ pub enum ClientRequest { req_id: u64, id: AgentId, }, + /// SLEEP: end the agent's process to free what it holds, keeping the + /// row (not archived). The next attach resumes its conversation, as + /// after an idle reap. A turn still running is cut short; the client + /// confirms that first. Gated on [`SLEEP_AGENT_PROTOCOL`]. + SleepAgent { + req_id: u64, + id: AgentId, + }, /// Respawn; resumes the stored session id (`claude --resume` / /// `codex resume` / `cursor-agent --resume`) when one is stored. RestartAgent { diff --git a/crates/nebula-daemon/src/registry.rs b/crates/nebula-daemon/src/registry.rs index 8fdb7651..54995423 100644 --- a/crates/nebula-daemon/src/registry.rs +++ b/crates/nebula-daemon/src/registry.rs @@ -2010,6 +2010,49 @@ mod tests { daemon.kill_session(&sref); } + /// SLEEP ends the process and keeps the row, not archived and not + /// alive; the next attach boots it again, as after an idle reap. A + /// working agent is refused and keeps running, as is an archived row. + #[tokio::test] + async fn sleep_kills_an_idle_process_and_keeps_the_row() { + let _cmd = EnvGuard::set(env::AGENT_CMD, "/bin/cat"); + let daemon = test_daemon(); + let dir = tempfile::tempdir().unwrap(); + seed_projects(&daemon, &["p"]); + seed_worktree(&daemon, "p", "w", dir.path().to_str().unwrap(), false); + seed_agent(&daemon, "a", "w", Some("sid-1")); + let id = AgentId("a".into()); + let sref = SessionRef::Agent(id.clone()); + daemon.ensure_session(&sref, 80, 24).unwrap(); + + daemon + .store + .set_agent_status(&id, AgentStatus::Running) + .unwrap(); + let err = daemon.sleep_agent(&id).unwrap_err(); + assert!(err.to_string().contains("still working"), "{err}"); + assert!(daemon.is_alive(&sref), "a working agent keeps running"); + + daemon + .store + .set_agent_status(&id, AgentStatus::Finished) + .unwrap(); + daemon.sleep_agent(&id).unwrap(); + assert!(!daemon.is_alive(&sref), "the process is gone"); + let row = daemon.store.get_agent(&id).unwrap().unwrap(); + assert!(!row.archived, "the row stays in the list"); + + daemon.ensure_session(&sref, 80, 24).unwrap(); + assert!(daemon.is_alive(&sref), "attach wakes it"); + daemon.kill_session(&sref); + + daemon.store.set_agent_archived(&id, true).unwrap(); + assert!( + daemon.sleep_agent(&id).is_err(), + "an archived row has nothing to sleep" + ); + } + /// `nebula worktree ` onto a row whose checkout is gone refuses /// before anything moves: the agent's row stays where it was and no /// relocation is queued, so the turn end never kills it for a respawn diff --git a/crates/nebula-daemon/src/registry/lifecycle.rs b/crates/nebula-daemon/src/registry/lifecycle.rs index 5f809ec7..77cc2dd0 100644 --- a/crates/nebula-daemon/src/registry/lifecycle.rs +++ b/crates/nebula-daemon/src/registry/lifecycle.rs @@ -444,6 +444,32 @@ impl Daemon { Ok(()) } + /// SLEEP: end the agent's process to free what it holds, and keep the + /// row as it is. The next attach resumes its conversation, as after an + /// idle reap (`reap_idle_sessions`). Refused while the agent works, on + /// the reaper's own test: running, waiting on the user, or a + /// backgrounded tool call still going. + pub fn sleep_agent(self: &Arc, id: &AgentId) -> Result<()> { + let agent = self.store.get_agent(id)?.context("agent not found")?; + if agent.archived { + bail!("agent is archived, so it is already asleep"); + } + let sref = SessionRef::Agent(id.clone()); + let session = self.sessions.lock().get(&sref).cloned(); + let busy = matches!( + agent.status, + AgentStatus::Running | AgentStatus::NeedsFeedback + ) || session.is_some_and(|s| agent_has_detached_job(&s)); + if busy { + bail!( + "'{}' is still working; sleep it once it is idle", + agent.name + ); + } + self.kill_session(&sref); + self.broadcast_agent(id) + } + pub fn unarchive_agent(self: &Arc, id: &AgentId) -> Result<()> { self.store.set_agent_archived(id, false)?; self.broadcast_agent(id)?; diff --git a/crates/nebula-daemon/src/server.rs b/crates/nebula-daemon/src/server.rs index 9ccfb177..0478d25f 100644 --- a/crates/nebula-daemon/src/server.rs +++ b/crates/nebula-daemon/src/server.rs @@ -565,6 +565,13 @@ impl ClientConnection { reply_done(&self.out_tx, req_id, result).await; Ok(true) } + ClientRequest::SleepAgent { req_id, id } => { + let result = self + .blocking_daemon(move |daemon| daemon.sleep_agent(&id)) + .await; + reply_done(&self.out_tx, req_id, result).await; + Ok(true) + } ClientRequest::RestartAgent { req_id, id } => { let result = self.daemon.restart_agent(&id).await; reply_done(&self.out_tx, req_id, result).await; diff --git a/crates/nebula-tui/src/app.rs b/crates/nebula-tui/src/app.rs index b1674fbd..b640a848 100644 --- a/crates/nebula-tui/src/app.rs +++ b/crates/nebula-tui/src/app.rs @@ -257,6 +257,8 @@ pub fn clamp_files_width(area: Rect, boundary_x: i32) -> Option { pub enum MenuAction { Attach(SessionRef), RestartAgent(AgentId), + /// A card menu's **Sleep**: what `z` runs (`actions::sleep_agent`). + SleepAgent(AgentId), /// Queue a message on the row's Claude Cloud session /// (`ClientRequest::SendCloudMessage`), via a prompt. SendCloudMessage(AgentId), @@ -686,6 +688,9 @@ pub enum PendingAction { terminals: Vec, }, DeleteAgent(AgentId), + /// `z` (or the card menu's Sleep): sleep the agent once the dialog is + /// answered. + SleepAgent(AgentId), /// A session card dropped on another band or PROJECT TAB, with the /// **Confirm drag move** SETTING on: move it once the dialog is /// answered. diff --git a/crates/nebula-tui/src/event_loop/actions.rs b/crates/nebula-tui/src/event_loop/actions.rs index 5582b1ad..8b9b9e75 100644 --- a/crates/nebula-tui/src/event_loop/actions.rs +++ b/crates/nebula-tui/src/event_loop/actions.rs @@ -128,6 +128,64 @@ pub(crate) fn confirm_delete_thread(app: &App, band: &crate::launcher::Band) -> with_worktree_offer(app, dialog, &band.worktree, band.cards.len()) } +/// SLEEP (`z`, and a card menu's **Sleep**): end the session's process to +/// free its memory and CPU, keeping its card where it is. Attaching wakes +/// it, resumed, as after an idle reap. Always behind a confirm +/// ([`confirm_sleep_agent`]); refused while the agent works, since sleep +/// would cut that short (the daemon refuses a backgrounded job too). +pub(crate) fn sleep_agent(app: &mut App, id: AgentId) { + let Some(a) = app.tree.agents.iter().find(|a| a.id == id) else { + return; + }; + if a.cloud_session_id.is_some() { + app.chrome.flash = Some("a cloud session runs in the cloud, nothing to sleep here".into()); + return; + } + if a.archived || !a.alive { + app.chrome.flash = Some(format!("'{}' is already asleep; attach wakes it", a.name)); + return; + } + if matches!( + a.status, + nebula_core::AgentStatus::Running | nebula_core::AgentStatus::NeedsFeedback + ) { + app.chrome.flash = Some(format!( + "'{}' is still working; sleep it once it is idle", + a.name + )); + return; + } + app.modals.overlay = Some(Overlay::Confirm(confirm_sleep_agent(&a.name, id))); +} + +/// The sleep itself: release the pane if it shows the agent, then ask the +/// daemon. From the dialog's Enter (`run_pending_action`), or straight +/// from [`sleep_agent`] for a session sitting idle. +pub(crate) fn sleep_agent_now(app: &mut App, id: AgentId, out: &mut Vec) { + if app.requests.daemon_protocol < nebula_core::SLEEP_AGENT_PROTOCOL { + app.chrome.flash = Some("this daemon predates sleep; restart it to sleep sessions".into()); + return; + } + detach_if_attached(app, &SessionRef::Agent(id.clone()), out); + if let Some(a) = app.tree.agents.iter().find(|a| a.id == id) { + app.chrome.flash = Some(format!("'{}' is asleep; attach wakes it", a.name)); + } + send(app, out, |req_id| ClientRequest::SleepAgent { req_id, id }); +} + +/// The confirm before a session is put to sleep. The message says why +/// saying yes is cheap: attaching brings the conversation back. +pub(crate) fn confirm_sleep_agent(name: &str, id: AgentId) -> ConfirmDialog { + ConfirmDialog { + title: "Sleep agent".into(), + message: format!( + "Sleep '{name}'? Its process ends to free memory; attaching wakes it with the conversation." + ), + action: PendingAction::SleepAgent(id), + area: ratatui::layout::Rect::default(), + } +} + /// The confirm before an agent is archived. The message says why saying /// yes is cheap: `u` undoes it. pub(crate) fn confirm_archive_agent(name: &str, id: AgentId) -> ConfirmDialog { @@ -527,19 +585,26 @@ pub(crate) fn menu_items_for_session(a: &nebula_core::Agent) -> Vec { MenuItem::destructive("Delete", MenuAction::DeleteAgent(a.id.clone())), ] } else { - vec![ + let mut items = vec![ MenuItem::new( "Attach", MenuAction::Attach(SessionRef::Agent(a.id.clone())), ), MenuItem::new("Follow-up prompt", MenuAction::FollowUp), MenuItem::new("Restart", MenuAction::RestartAgent(a.id.clone())), + ]; + // Only a session with a process to end; a cold one is asleep already. + if a.alive { + items.push(MenuItem::new("Sleep", MenuAction::SleepAgent(a.id.clone()))); + } + items.extend([ MenuItem::new("Duplicate", MenuAction::DuplicateAgent(a.id.clone())), MenuItem::new("Move to…", MenuAction::MoveAgentPicker(a.id.clone())), MenuItem::new("Rename", MenuAction::RenameAgent(a.id.clone())), MenuItem::new("Archive", MenuAction::ArchiveAgent(a.id.clone())), MenuItem::destructive("Delete", MenuAction::DeleteAgent(a.id.clone())), - ] + ]); + items } } diff --git a/crates/nebula-tui/src/event_loop/commands.rs b/crates/nebula-tui/src/event_loop/commands.rs index e479c2a9..72ae42a2 100644 --- a/crates/nebula-tui/src/event_loop/commands.rs +++ b/crates/nebula-tui/src/event_loop/commands.rs @@ -336,6 +336,7 @@ pub(crate) fn run_pending_action( } } PendingAction::DeleteAgent(id) => delete_agent(app, id, out), + PendingAction::SleepAgent(id) => sleep_agent_now(app, id, out), PendingAction::MoveAgent { id, worktree } => launcher::move_agent(app, id, worktree, out), PendingAction::CloseTerminal(id) => close_terminal(app, id, out), PendingAction::DeleteLink(id) => optimistic::delete_link(app, id, out), @@ -560,6 +561,7 @@ pub(crate) fn run_menu_action(app: &mut App, action: MenuAction, out: &mut Vec sleep_agent(app, id), MenuAction::SendCloudMessage(id) => open_prompt(app, PromptKind::CloudMessage { id }), MenuAction::DuplicateAgent(id) => launcher::duplicate_agent(app, id), MenuAction::MoveAgentPicker(id) => launcher::open_move_picker(app, id), diff --git a/crates/nebula-tui/src/event_loop/key_modes.rs b/crates/nebula-tui/src/event_loop/key_modes.rs index 89dc0bc7..424a5c2d 100644 --- a/crates/nebula-tui/src/event_loop/key_modes.rs +++ b/crates/nebula-tui/src/event_loop/key_modes.rs @@ -314,6 +314,7 @@ fn handle_global_action( } Action::RefreshPullRequests | Action::Archive + | Action::Sleep | Action::Unarchive | Action::ToggleArchived | Action::Palette @@ -570,6 +571,17 @@ fn handle_session_state_action( } } } + Action::Sleep => { + if app.nav.focus == Focus::Sessions { + match app.selected_session_row() { + Some(SessionRow::Agent(a)) => sleep_agent(app, a.id), + Some(SessionRow::Terminal(_)) => { + app.chrome.flash = Some("terminals can't sleep; d closes them".into()); + } + _ => {} + } + } + } Action::Unarchive => { if app.nav.focus == Focus::Sessions { if let Some(a) = app.selected_session() { diff --git a/crates/nebula-tui/src/event_loop/launcher.rs b/crates/nebula-tui/src/event_loop/launcher.rs index 9dc53f62..8ce88b9c 100644 --- a/crates/nebula-tui/src/event_loop/launcher.rs +++ b/crates/nebula-tui/src/event_loop/launcher.rs @@ -922,6 +922,7 @@ fn acts_on_card(action: Action) -> bool { action, Action::Rename | Action::Archive + | Action::Sleep | Action::Unarchive | Action::Delete | Action::FollowUp @@ -7679,6 +7680,78 @@ mod tests { }); } + /// `z` sleeps the card under the cursor once its confirm is answered, + /// with no attach sent to wake it straight back; a running card is + /// refused, a cold one has nothing to sleep. The card menu offers + /// **Sleep** on a live card only. + #[test] + fn z_asks_then_sleeps_an_idle_card_and_refuses_a_running_one() { + let sleeps = |out: &[ClientRequest], id: &str| { + out.iter() + .any(|r| matches!(r, ClientRequest::SleepAgent { id: a, .. } if a.0 == id)) + }; + let menu_has_sleep = |app: &App, id: &str| { + let a = app.tree.agents.iter().find(|a| a.id.0 == id).unwrap(); + super::super::menu_items_for_session(a) + .iter() + .any(|i| i.label == "Sleep") + }; + with_default_config(|| { + let mut app = three_sessions(); + app.tree + .agents + .iter_mut() + .find(|a| a.id.0 == "a1") + .unwrap() + .status = AgentStatus::Finished; + draw(&mut app); + assert!(menu_has_sleep(&app, "a1"), "a live card offers Sleep"); + + super::select(&mut app, AgentId("a1".into()), &mut Vec::new()); + let out = key(&mut app, KeyCode::Char('z'), KeyModifiers::NONE); + assert!(!sleeps(&out, "a1"), "nothing sent before the answer"); + assert!( + matches!(&app.modals.overlay, Some(Overlay::Confirm(c)) + if c.action == PendingAction::SleepAgent(AgentId("a1".into()))), + "it asks: {:?}", + app.modals.overlay + ); + let out = key(&mut app, KeyCode::Enter, KeyModifiers::NONE); + assert!(sleeps(&out, "a1"), "Enter sleeps it: {out:?}"); + assert!( + !out.iter() + .any(|r| matches!(r, ClientRequest::Attach { .. })), + "nothing wakes it straight back: {out:?}" + ); + assert_eq!(cards(&app), ["a9", "a2", "a1"], "the card stays"); + + super::select(&mut app, AgentId("a2".into()), &mut Vec::new()); + let out = key(&mut app, KeyCode::Char('z'), KeyModifiers::NONE); + assert!(!sleeps(&out, "a2"), "running: refused"); + assert!(app.modals.overlay.is_none(), "running: no confirm"); + assert!(app + .chrome + .flash + .as_deref() + .unwrap_or("") + .contains("still working")); + + for a in app.tree.agents.iter_mut().filter(|a| a.id.0 == "a2") { + a.alive = false; + } + assert!(!menu_has_sleep(&app, "a2"), "a cold card has no Sleep"); + let out = key(&mut app, KeyCode::Char('z'), KeyModifiers::NONE); + assert!(!sleeps(&out, "a2"), "cold: nothing to sleep"); + assert!(app.modals.overlay.is_none()); + assert!(app + .chrome + .flash + .as_deref() + .unwrap_or("") + .contains("already asleep")); + }); + } + /// A held `a` opens ONE confirm and archives nothing by itself. The /// host repeats a held key — marked as repeats under the kitty /// protocol, as more presses without it — and every one lands on the diff --git a/crates/nebula-tui/src/keymap.rs b/crates/nebula-tui/src/keymap.rs index 971acb53..2adff6fa 100644 --- a/crates/nebula-tui/src/keymap.rs +++ b/crates/nebula-tui/src/keymap.rs @@ -114,6 +114,9 @@ pub enum Action { NewTerminal, Rename, Archive, + /// `z`: SLEEP the session card under the cursor. Its process ends and + /// frees what it holds; the card stays, and attaching wakes it. + Sleep, Unarchive, ToggleArchived, Delete, @@ -497,6 +500,15 @@ pub const ACTIONS: &[ActionSpec] = &[ scope: Scope::Global, defaults: &["a"], }, + ActionSpec { + action: Action::Sleep, + id: "sleep", + label: "Sleep session", + hint: "End the selected agent's process to free its memory, keeping its card; attaching wakes it with the conversation resumed. Asks first; refused while the agent is working", + group: "SESSIONS", + scope: Scope::Global, + defaults: &["z"], + }, ActionSpec { action: Action::Unarchive, id: "unarchive", diff --git a/docs/keys.md b/docs/keys.md index cea64c90..84c6ecab 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -53,6 +53,7 @@ The grid isn't the only view. With a session selected: | The grid (a card) | `Shift+I` | open the GitHub issue the card's session was started from — an ISSUE SESSION, launched out of the ISSUES MODAL — in the browser. `i` lists the project's issues in nebula; `Shift+I` goes to GitHub. A card that was not started from an issue says so; with no card selected (after `Esc`) nothing opens. The card's right-click menu has it as **Open issue**; with **Card issue number** on (Settings → Appearance, off by default) the card shows the issue's `#15` at the right of its second row, and a click on it does the same; rebindable as `open_issue` | | The grid (a card) | `Shift+P` | open the QUICK PROMPT set to launch what the card runs — the same harness, model and effort, into the same checkout, for the same issue when the card is an ISSUE SESSION — so the task is all there is to type: Enter there sends it with those settings, exactly as a spec picked through the box's `Tab` / `^O` would, and every picker of the box still works on it. `p` opens the box on the Agents tab defaults, in the checkout under the cursor; `Shift+P` opens it on the card. The card's conversation is not copied (the new session is a fresh CLI). A cloud card comes up as a cloud box on its model and effort. With no card selected (after `Esc`) nothing opens. The card's right-click menu has it as **Duplicate**; rebindable as `duplicate_session` | | The grid (a card) | `m` | open the MOVE PICKER: every other checkout of the card's project, root first, then every other project's as `project ▸ branch` (type a project's name to narrow to it). Enter on one moves the session there: the card jumps to that band at once, and the CLI is killed and resumed in the new checkout (same conversation, via its resume flag). An idle session moves straight away; one mid-turn finishes its turn first, then follows. Dragging the card onto another band's rule or cards does the same, and the band under the pointer reads `release to move here`; dropped on another PROJECT TAB, it moves into that project's root, and the tab lights while the card is over it. The air under a band, down to the next one, counts as that band, so an empty band takes a drop past its rule. A drop asks first (`Move 'name' to branch?`, Enter moves, Esc keeps it) unless **Confirm drag move** is off (Settings → Sessions, `confirm_drag_move`); the picker never asks. A session moved to another project takes the grid there with it. With no card selected (after `Esc`) nothing opens. The card's right-click menu has it as **Move to…**; rebindable as `move_session` | +| The grid (a card) | `z` | **sleep.** End the card's agent process to free the memory and CPU it holds (the CLI and the MCP servers it started), keeping the card where it is: not archived, it reads as a cold card. Attaching it again (`Enter`, a click, or resting the cursor on it) wakes it, and the agent RESUMES its conversation, as after an IDLE REAP. The IDLE REAPER never takes a session in the worktree you are viewing, so `z` is how to free one of those. It asks first (`Sleep 'name'?`, `Enter` or `y` sleeps, `Esc` or `n` keeps it running). A session that is working (mid-turn, waiting on you, or with a backgrounded tool call still going) is refused: sleep it once it is idle. A cold, archived or cloud card has nothing to sleep and says so. The card's right-click menu has it as **Sleep**, on live cards only; rebindable as `sleep` | | The grid | `Shift+T` | `t`'s terminal outside nebula: a new Ghostty tab in the selected worktree's directory (the project's clone when it has no worktrees yet): nebula hands the directory to Ghostty.app with `open -a`, which Ghostty takes like a folder dropped on its Dock icon — a tab in the front window, or a new window if `macos-dock-drop-behavior = new-window` is set. Without Ghostty.app in `/Applications` or `~/Applications`, off macOS, or over ssh, the key does nothing at all, not even a footer message. Rebindable as `open_ghostty_tab` in Settings → Hotkeys | | The grid | `f` / `F` / `b` | find file / find in files (`git grep`) / file tree browser, all scoped to the selected worktree — `Enter` opens the file in an editor modal (at the matched line, for `F`; a `.md` from `f` opens read-first in the file tabs instead); in `f` and `b`, `Ctrl+y` copies the path | | PALETTE, FILE FINDER (and its grep view), TREE BROWSER, BRANCH SWITCHER | `↑`/`↓` or `Ctrl+n`/`Ctrl+p` | move the selection inside a fuzzy overlay. `j`/`k` deliberately do *not* — they are typed into the query, so a branch or filename with a `j` in it stays reachable — which makes these five the one place the app-wide `j`/`k` convention above stops at the modal's edge. `Esc` is two-staged here as well: the first press clears what you typed, and only the second closes | diff --git a/scripts/shot/scenes/sleep-after.keys b/scripts/shot/scenes/sleep-after.keys new file mode 100644 index 00000000..89733784 --- /dev/null +++ b/scripts/shot/scenes/sleep-after.keys @@ -0,0 +1,12 @@ +# sleep-confirm, answered: the card stays, cold, and the footer says how to wake it. +Tab +Tab +p +Enter +wait 2 +p +Enter +wait 2 +z +y +wait 1 diff --git a/scripts/shot/scenes/sleep-confirm.keys b/scripts/shot/scenes/sleep-confirm.keys new file mode 100644 index 00000000..3d512291 --- /dev/null +++ b/scripts/shot/scenes/sleep-confirm.keys @@ -0,0 +1,11 @@ +# Two idle sessions in the root checkout (an empty quick prompt starts one with no first +# prompt, so nothing is running), then `z` on the second: the SLEEP confirm. +Tab +Tab +p +Enter +wait 2 +p +Enter +wait 2 +z