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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion crates/nebula-core/src/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use std::path::PathBuf;
///
/// Bump on every protocol change. Additive changes keep
/// [`MIN_COMPATIBLE_PROTOCOL`] where it is; breaking changes bump both.
pub const PROTOCOL_VERSION: u32 = 51;
pub const PROTOCOL_VERSION: u32 = 52;

/// Oldest IPC protocol this build can safely talk to.
///
Expand All @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
43 changes: 43 additions & 0 deletions crates/nebula-daemon/src/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <branch>` 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
Expand Down
26 changes: 26 additions & 0 deletions crates/nebula-daemon/src/registry/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self>, 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<Self>, id: &AgentId) -> Result<()> {
self.store.set_agent_archived(id, false)?;
self.broadcast_agent(id)?;
Expand Down
7 changes: 7 additions & 0 deletions crates/nebula-daemon/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions crates/nebula-tui/src/app.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
//! TUI state: the Elm-ish Model.

use crate::git_diff::DiffFile;
Expand Down Expand Up @@ -257,6 +257,8 @@
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),
Expand Down Expand Up @@ -686,6 +688,9 @@
terminals: Vec<TerminalId>,
},
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.
Expand Down
69 changes: 67 additions & 2 deletions crates/nebula-tui/src/event_loop/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ClientRequest>) {
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 {
Expand Down Expand Up @@ -527,19 +585,26 @@ pub(crate) fn menu_items_for_session(a: &nebula_core::Agent) -> Vec<MenuItem> {
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
}
}

Expand Down
2 changes: 2 additions & 0 deletions crates/nebula-tui/src/event_loop/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -560,6 +561,7 @@ pub(crate) fn run_menu_action(app: &mut App, action: MenuAction, out: &mut Vec<C
id,
});
}
MenuAction::SleepAgent(id) => 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),
Expand Down
12 changes: 12 additions & 0 deletions crates/nebula-tui/src/event_loop/key_modes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@ fn handle_global_action(
}
Action::RefreshPullRequests
| Action::Archive
| Action::Sleep
| Action::Unarchive
| Action::ToggleArchived
| Action::Palette
Expand Down Expand Up @@ -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() {
Expand Down
73 changes: 73 additions & 0 deletions crates/nebula-tui/src/event_loop/launcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -922,6 +922,7 @@ fn acts_on_card(action: Action) -> bool {
action,
Action::Rename
| Action::Archive
| Action::Sleep
| Action::Unarchive
| Action::Delete
| Action::FollowUp
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions crates/nebula-tui/src/keymap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading