Compare commits

...
Author SHA1 Message Date
Miguel Palhas 9728b10ffd feat(maestro): add preset resume commands 2026-09-04 08:33:08 +01:00
Miguel Palhas 1b2b392710 merge: dormant session recovery (#162)
Closes #162
2026-09-04 08:25:44 +01:00
Miguel PalhasandClaude Opus 5 72644dd564 feat(maestro): register dormant sessions on recovery
A host reboot kills the tmux server, so `SessionManager::recover` iterated an
empty list and every session vanished from the API while its sidecar sat
untouched on disk. A second recovery pass now reads the sidecars tmux had
nothing to adopt and registers each as `SessionStatus::Dormant` — a record of
what was running, with no pane and no PTY bridge behind it.

`SessionStatus` deserializes leniently now, like `ActivityState`: an unknown
spelling reads as `Dead` rather than failing the whole `Session`.

Refs #162

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 08:22:52 +01:00
Miguel Palhas 8325bc6d9a Merge remote-tracking branch 'origin/main' into blitz/command-bar
ci / web (push) Successful in 37s
ci / flake (push) Successful in 2m52s
ci / rust (push) Successful in 5m44s
2026-09-03 22:58:41 +01:00
Miguel PalhasandClaude Opus 5 8f499dbee3 fix(maestro): arbitrate pane size across clients
ci / web (push) Successful in 52s
ci / flake (push) Successful in 2m35s
ci / rust (push) Successful in 5m57s
Every attached client pushed its own dimensions straight to
`tmux resize-window`, so two clients of different sizes resized the
shared window past each other on every report — 198 resizes in 90
seconds on a four-session daemon. Each flip repaints a full-screen agent
at a new height and orphans the rows it drew at the old one, leaving a
stale input box the operator types into and reads as a stuck pane.

The window now goes to the smallest attached client per axis, tmux's own
rule for multiple clients, and only when that size actually changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 22:28:40 +01:00
Miguel Palhas 511fed408c feat(maestro): confirm every kill in the web client
The TUI has always raised `KillConfirm` before `x` kills anything; the web
sent `DELETE /api/sessions/:id` on the first press and only prompted when the
daemon refused. The command bar puts both behind the same three keystrokes,
so the difference now reads as a bug (#160).

The web follows the TUI: a kill is destructive and unattended agents live in
these sessions. `killDialog.ts` grows a confirmation that shares the force
prompt's shell, names the blockers the card already knows about, and keeps the
focus on Cancel. The guarded refusal still opens its own dialog behind it.
2026-09-03 22:08:06 +01:00
Miguel Palhas ba02b11f13 feat(maestro): serve the configured leader to the web client
The TUI read its leader from `[tui].leader`; the web used `DEFAULT_LEADER`,
a module constant. Rebinding one left the other on `ctrl-a`, so the two
escape hatches hanging off the leader (`leader ctrl-k`, `leader leader`)
moved on one client and not the other.

`GET /api/info` now carries `leader`. The web binds that when it arrives and
rebuilds the leader-owning `CommandSet`, so the `?` overlay and the command
bar render the configured chord; an absent or empty field — an older daemon,
or an unreachable `/api/info` — leaves it on `ctrl-a`.

Chose the existing `[tui].leader` served as-is over a new shared key: one
knob, no precedence rules between two spellings, and no migration for
operators who already set it. The name is a wart — it is the leader for every
client now, not the TUI's — and `docs/COMMAND-BAR.md` and `docs/INSTALL.md`
say so.
2026-09-03 18:33:01 +01:00
39 changed files with 1256 additions and 133 deletions
+1
View File
@@ -188,6 +188,7 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
let status = match s.status { let status = match s.status {
SessionStatus::Running => "running", SessionStatus::Running => "running",
SessionStatus::Dead => "dead", SessionStatus::Dead => "dead",
SessionStatus::Dormant => "dormant",
}; };
let activity = match s.activity { let activity = match s.activity {
ActivityState::Active => "active", ActivityState::Active => "active",
@@ -28,6 +28,7 @@ async fn info_carries_version_and_presets() {
id: "claude".into(), id: "claude".into(),
label: "Claude".into(), label: "Claude".into(),
command: "claude".into(), command: "claude".into(),
resume_command: None,
}], }],
..Default::default() ..Default::default()
}); });
+57
View File
@@ -30,6 +30,11 @@ pub const FILE_NAME: &str = "config.toml";
/// Last-resort daemon HTTP base, matching the daemon's own default `bind_addr`. /// Last-resort daemon HTTP base, matching the daemon's own default `bind_addr`.
pub const DEFAULT_DAEMON_URL: &str = "http://127.0.0.1:7070"; pub const DEFAULT_DAEMON_URL: &str = "http://127.0.0.1:7070";
/// Leader key both clients use when `[tui].leader` is unset (#159). Canonical
/// key form, the spelling `InputKey::parse` and the web's `canonicalizeKey`
/// both produce.
pub const DEFAULT_LEADER: &str = "ctrl-a";
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum ConfigError { pub enum ConfigError {
#[error("io: {0}")] #[error("io: {0}")]
@@ -152,6 +157,15 @@ pub struct TuiSection {
pub keymap_insert: BTreeMap<String, String>, pub keymap_insert: BTreeMap<String, String>,
} }
impl TuiSection {
/// The configured leader, or [`DEFAULT_LEADER`]. Named `[tui].leader` for
/// history, but read by every client: the daemon serves it on
/// `GET /api/info` so the web follows the same knob (#159).
pub fn leader(&self) -> &str {
self.leader.as_deref().unwrap_or(DEFAULT_LEADER)
}
}
/// Palette presets supported by the TUI's `[tui].theme` setting. /// Palette presets supported by the TUI's `[tui].theme` setting.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TuiTheme { pub enum TuiTheme {
@@ -299,16 +313,19 @@ pub fn builtin_presets() -> Vec<SpawnPreset> {
id: "claude".into(), id: "claude".into(),
label: "Claude".into(), label: "Claude".into(),
command: "claude".into(), command: "claude".into(),
resume_command: Some("claude --resume {agent_session}".into()),
}, },
SpawnPreset { SpawnPreset {
id: "opencode".into(), id: "opencode".into(),
label: "opencode".into(), label: "opencode".into(),
command: "opencode".into(), command: "opencode".into(),
resume_command: None,
}, },
SpawnPreset { SpawnPreset {
id: "zsh".into(), id: "zsh".into(),
label: "Shell".into(), label: "Shell".into(),
command: "zsh".into(), command: "zsh".into(),
resume_command: None,
}, },
] ]
} }
@@ -320,6 +337,7 @@ pub fn custom_preset() -> SpawnPreset {
id: CUSTOM_PRESET_ID.into(), id: CUSTOM_PRESET_ID.into(),
label: "Custom…".into(), label: "Custom…".into(),
command: String::new(), command: String::new(),
resume_command: None,
} }
} }
@@ -493,6 +511,18 @@ auto_install = false
assert!(custom.command.is_empty()); assert!(custom.command.is_empty());
} }
#[test]
fn builtin_claude_preset_resumes_by_agent_session() {
let claude = builtin_presets()
.into_iter()
.find(|preset| preset.id == "claude")
.expect("Claude preset");
assert_eq!(
claude.resume_command.as_deref(),
Some("claude --resume {agent_session}")
);
}
#[test] #[test]
fn spawn_presets_override_replaces_builtins() { fn spawn_presets_override_replaces_builtins() {
// Present list replaces the built-ins entirely; custom still appended. // Present list replaces the built-ins entirely; custom still appended.
@@ -501,6 +531,7 @@ auto_install = false
id: "fish".into(), id: "fish".into(),
label: "fish".into(), label: "fish".into(),
command: "fish".into(), command: "fish".into(),
resume_command: None,
}]), }]),
..Default::default() ..Default::default()
}; };
@@ -537,6 +568,7 @@ auto_install = false
id: " ".into(), id: " ".into(),
label: "x".into(), label: "x".into(),
command: "x".into(), command: "x".into(),
resume_command: None,
}]), }]),
..Default::default() ..Default::default()
}; };
@@ -547,6 +579,7 @@ auto_install = false
id: "x".into(), id: "x".into(),
label: "x".into(), label: "x".into(),
command: "".into(), command: "".into(),
resume_command: None,
}]), }]),
..Default::default() ..Default::default()
}; };
@@ -564,11 +597,13 @@ auto_install = false
id: "a".into(), id: "a".into(),
label: "a".into(), label: "a".into(),
command: "a".into(), command: "a".into(),
resume_command: None,
}, },
SpawnPreset { SpawnPreset {
id: "a".into(), id: "a".into(),
label: "a2".into(), label: "a2".into(),
command: "a2".into(), command: "a2".into(),
resume_command: None,
}, },
]), ]),
..Default::default() ..Default::default()
@@ -583,6 +618,7 @@ auto_install = false
id: "custom".into(), id: "custom".into(),
label: "Custom".into(), label: "Custom".into(),
command: "whatever".into(), command: "whatever".into(),
resume_command: None,
}]), }]),
..Default::default() ..Default::default()
}; };
@@ -618,6 +654,27 @@ presets = [
assert_eq!(ids, ["claude", "zsh", "custom"]); assert_eq!(ids, ["claude", "zsh", "custom"]);
} }
#[test]
fn spawn_preset_parses_resume_command_from_issue_example() {
let cfg = toml::from_str::<MaestroConfigFile>(
r#"
[spawn]
presets = [
{ id = "claude", command = "claude --dangerously-skip-permissions", resume_command = "claude --dangerously-skip-permissions --resume {agent_session}" }
]
"#,
)
.expect("issue #165 example parses");
let preset = &cfg.spawn.presets.expect("configured presets")[0];
assert_eq!(preset.id, "claude");
assert_eq!(preset.command, "claude --dangerously-skip-permissions");
assert_eq!(
preset.resume_command.as_deref(),
Some("claude --dangerously-skip-permissions --resume {agent_session}")
);
}
#[test] #[test]
fn spawn_project_scan_fields_default_to_none() { fn spawn_project_scan_fields_default_to_none() {
// Absent [spawn].project_* → None; the daemon applies its own defaults. // Absent [spawn].project_* → None; the daemon applies its own defaults.
+9 -1
View File
@@ -26,11 +26,17 @@ pub(crate) async fn serve(
) -> Result<(), axum::Error> { ) -> Result<(), axum::Error> {
let (mut sink, mut stream) = socket.split(); let (mut sink, mut stream) = socket.split();
// This client's claim on the shared window size. Registered up front and
// released on every exit below, so a socket that dies mid-session stops
// constraining the size for the clients still attached.
let viewport = manager.attach_viewport();
// NAP-229: send buffered scrollback up-front so the client sees the last // NAP-229: send buffered scrollback up-front so the client sees the last
// screenful immediately instead of a blank terminal. The replay lock the // screenful immediately instead of a blank terminal. The replay lock the
// bridge held during snapshot+subscribe guarantees no byte appears both // bridge held during snapshot+subscribe guarantees no byte appears both
// here and on the live receiver, and none is dropped at the seam. // here and on the live receiver, and none is dropped at the seam.
if !replay.is_empty() && sink.send(Message::Binary(replay)).await.is_err() { if !replay.is_empty() && sink.send(Message::Binary(replay)).await.is_err() {
manager.release_viewport(id, viewport).await;
return Ok(()); return Ok(());
} }
@@ -70,7 +76,7 @@ pub(crate) async fn serve(
if let Ok(ctrl) = serde_json::from_str::<ClientControl>(&text) { if let Ok(ctrl) = serde_json::from_str::<ClientControl>(&text) {
match ctrl { match ctrl {
ClientControl::Resize { cols, rows } => { ClientControl::Resize { cols, rows } => {
let _ = manager.resize(id, cols, rows).await; let _ = manager.set_viewport(id, viewport, cols, rows).await;
} }
ClientControl::Ping => { ClientControl::Ping => {
let pong = serde_json::to_string(&ClientControl::Pong) let pong = serde_json::to_string(&ClientControl::Pong)
@@ -88,6 +94,8 @@ pub(crate) async fn serve(
} }
} }
manager.release_viewport(id, viewport).await;
if session_ended { if session_ended {
let _ = sink.send(Message::Close(None)).await; let _ = sink.send(Message::Close(None)).await;
} }
+1
View File
@@ -105,6 +105,7 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
config.home_dir.clone(), config.home_dir.clone(),
config.spawn_presets.clone(), config.spawn_presets.clone(),
projects, projects,
config.leader.clone(),
)); ));
let listener = TcpListener::bind(config.bind_addr).await?; let listener = TcpListener::bind(config.bind_addr).await?;
+29
View File
@@ -92,6 +92,11 @@ pub struct DaemonConfig {
/// start when none is present (#106): `[hooks].auto_install`, default /// start when none is present (#106): `[hooks].auto_install`, default
/// `true`. /// `true`.
pub hooks_auto_install: bool, pub hooks_auto_install: bool,
/// Canonical leader key advertised to clients in `GET /api/info` (#159):
/// `[tui].leader`, default [`maestro_config::DEFAULT_LEADER`]. The daemon
/// never binds it itself; it serves it so the web client and the TUI read
/// one knob.
pub leader: String,
} }
impl DaemonConfig { impl DaemonConfig {
@@ -197,6 +202,7 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
project_roots, project_roots,
project_scan_depth, project_scan_depth,
hooks_auto_install: file.hooks.auto_install(), hooks_auto_install: file.hooks.auto_install(),
leader: file.tui.leader().to_string(),
}) })
} }
@@ -233,6 +239,29 @@ pub fn system_hostname() -> String {
mod tests { mod tests {
use super::*; use super::*;
/// #159: the leader the operator set for the TUI is what the daemon
/// advertises, so rebinding it once moves both clients' chord.
#[test]
fn leader_defaults_to_ctrl_a_and_follows_the_tui_section() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "").unwrap();
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
assert_eq!(cfg.leader, "ctrl-a");
std::fs::write(
&path,
r#"
[tui]
leader = "ctrl-b"
"#,
)
.unwrap();
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
assert_eq!(cfg.leader, "ctrl-b");
}
#[test] #[test]
fn hooks_auto_install_defaults_true_and_is_opt_out() { fn hooks_auto_install_defaults_true_and_is_opt_out() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
+1
View File
@@ -30,6 +30,7 @@ pub mod spawn_history;
#[cfg(feature = "web")] #[cfg(feature = "web")]
pub mod static_bundle; pub mod static_bundle;
pub mod tmux; pub mod tmux;
pub mod viewport;
pub mod worktree; pub mod worktree;
pub mod worktree_probe; pub mod worktree_probe;
+7
View File
@@ -39,6 +39,10 @@ pub struct LocalApiState {
pub presets: Arc<Vec<SpawnPreset>>, pub presets: Arc<Vec<SpawnPreset>>,
/// Project discovery (#54) backing `GET /api/projects`. /// Project discovery (#54) backing `GET /api/projects`.
pub projects: Arc<crate::projects::ProjectScanner>, pub projects: Arc<crate::projects::ProjectScanner>,
/// Configured leader key (`[tui].leader`) advertised in `GET /api/info`
/// (#159), so the web client binds the chord the operator configured
/// rather than its own default.
pub leader: Arc<String>,
} }
pub fn router(state: LocalApiState) -> Router { pub fn router(state: LocalApiState) -> Router {
@@ -219,6 +223,7 @@ async fn daemon_info(State(s): State<LocalApiState>) -> Json<DaemonInfo> {
presets: (*s.presets).clone(), presets: (*s.presets).clone(),
build: crate::release::build_info(), build: crate::release::build_info(),
instance: crate::release::instance_id().to_string(), instance: crate::release::instance_id().to_string(),
leader: (*s.leader).clone(),
}) })
} }
@@ -374,6 +379,7 @@ pub fn state_for(
home_dir: impl Into<String>, home_dir: impl Into<String>,
presets: Vec<SpawnPreset>, presets: Vec<SpawnPreset>,
projects: Arc<crate::projects::ProjectScanner>, projects: Arc<crate::projects::ProjectScanner>,
leader: impl Into<String>,
) -> LocalApiState { ) -> LocalApiState {
LocalApiState { LocalApiState {
manager, manager,
@@ -381,6 +387,7 @@ pub fn state_for(
home_dir: Arc::new(home_dir.into()), home_dir: Arc::new(home_dir.into()),
presets: Arc::new(presets), presets: Arc::new(presets),
projects, projects,
leader: Arc::new(leader.into()),
} }
} }
+176 -22
View File
@@ -7,6 +7,7 @@
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
@@ -23,6 +24,7 @@ use crate::pty::{pipe_pane_command, PtyBridge};
use crate::sidecar::{SidecarError, SidecarStore}; use crate::sidecar::{SidecarError, SidecarStore};
use crate::spawn_history::{RecentDir, SpawnHistory, SpawnHistoryRecord}; use crate::spawn_history::{RecentDir, SpawnHistory, SpawnHistoryRecord};
use crate::tmux::TmuxError; use crate::tmux::TmuxError;
use crate::viewport::{ViewportId, Viewports};
use crate::worktree::{WorktreeError, WorktreeManager}; use crate::worktree::{WorktreeError, WorktreeManager};
/// Capacity of the session-events broadcast channel. A subscriber that falls /// Capacity of the session-events broadcast channel. A subscriber that falls
@@ -60,7 +62,10 @@ pub struct SessionRecord {
/// `base_repo` from the original `WorktreeSpec`. Needed because `git /// `base_repo` from the original `WorktreeSpec`. Needed because `git
/// worktree remove` is invoked from the base repo. /// worktree remove` is invoked from the base repo.
pub worktree_base_repo: Option<PathBuf>, pub worktree_base_repo: Option<PathBuf>,
pub bridge: PtyBridge, /// The PTY bridge feeding this session's attach stream. `None` for a
/// `Dormant` session (#162): there is no pane behind it, so there is
/// nothing to pipe and nothing to attach to.
pub bridge: Option<PtyBridge>,
/// Agent-reported activity (#105) still within its TTL. While in force it /// Agent-reported activity (#105) still within its TTL. While in force it
/// outranks the activity pass's own inference; when it lapses, inference /// outranks the activity pass's own inference; when it lapses, inference
/// resumes and [`Session::activity_detail`] is cleared with it. This /// resumes and [`Session::activity_detail`] is cleared with it. This
@@ -166,6 +171,14 @@ struct Inner {
report_ttl: Duration, report_ttl: Duration,
sessions: Mutex<HashMap<Uuid, SessionRecord>>, sessions: Mutex<HashMap<Uuid, SessionRecord>>,
events: broadcast::Sender<CatalogEvent>, events: broadcast::Sender<CatalogEvent>,
/// Sizes reported by the clients attached to each session, and what tmux
/// was last told. See [`crate::viewport`] for why the window is not simply
/// set to whatever arrived most recently.
viewports: Mutex<Viewports>,
/// Hands out [`ViewportId`]s. Monotonic for the daemon's lifetime, so a
/// client that reconnects never collides with the registration its
/// previous socket has not finished releasing.
next_viewport: AtomicU64,
} }
impl SessionManager { impl SessionManager {
@@ -206,6 +219,8 @@ impl SessionManager {
report_ttl: crate::activity::REPORT_TTL, report_ttl: crate::activity::REPORT_TTL,
sessions: Mutex::new(HashMap::new()), sessions: Mutex::new(HashMap::new()),
events, events,
viewports: Mutex::new(Viewports::new()),
next_viewport: AtomicU64::new(0),
}), }),
} }
} }
@@ -279,7 +294,10 @@ impl SessionManager {
/// unknown to this daemon. /// unknown to this daemon.
pub async fn subscribe_pty(&self, id: Uuid) -> Option<broadcast::Receiver<Vec<u8>>> { pub async fn subscribe_pty(&self, id: Uuid) -> Option<broadcast::Receiver<Vec<u8>>> {
let sessions = self.inner.sessions.lock().await; let sessions = self.inner.sessions.lock().await;
sessions.get(&id).map(|r| r.bridge.subscribe()) sessions
.get(&id)
.and_then(|r| r.bridge.as_ref())
.map(|b| b.subscribe())
} }
/// Subscribe to a session's PTY broadcast and return the replay buffer /// Subscribe to a session's PTY broadcast and return the replay buffer
@@ -298,19 +316,26 @@ impl SessionManager {
let (result, updated) = { let (result, updated) = {
let mut sessions = self.inner.sessions.lock().await; let mut sessions = self.inner.sessions.lock().await;
let rec = sessions.get_mut(&id)?; let rec = sessions.get_mut(&id)?;
rec.session.last_focused_at = Some(Utc::now()); let SessionRecord {
let updated = rec.session.clone(); session, bridge, ..
} = &mut *rec;
// #162: a dormant session has no bridge and no pane — nothing was
// ever piped for it, so there is no stream to hand back and the
// attach reads as unknown. Checked before anything is stamped.
let bridge = bridge.as_ref()?;
session.last_focused_at = Some(Utc::now());
let updated = session.clone();
// For a live session, tmux's own screen beats the byte tail: the // For a live session, tmux's own screen beats the byte tail: the
// tail starts mid-stream, so a full-screen program (an agent TUI) // tail starts mid-stream, so a full-screen program (an agent TUI)
// replayed from it renders as garbage until something forces a // replayed from it renders as garbage until something forces a
// repaint. A dead session has no pane left to capture, so it // repaint. A dead session has no pane left to capture, so it
// keeps the byte tail. // keeps the byte tail.
let alive = rec.session.status == SessionStatus::Running; let alive = session.status == SessionStatus::Running;
// The capture shells out to tmux while the replay lock is held, // The capture shells out to tmux while the replay lock is held,
// which parks the reader for the length of one tmux round-trip. // which parks the reader for the length of one tmux round-trip.
// That only happens on attach, and the alternative is a seam in // That only happens on attach, and the alternative is a seam in
// the stream. // the stream.
let result = rec.bridge.subscribe_with_snapshot(|| { let result = bridge.subscribe_with_snapshot(|| {
if !alive { if !alive {
return None; return None;
} }
@@ -487,7 +512,7 @@ impl SessionManager {
fifo_path, fifo_path,
worktree_path, worktree_path,
worktree_base_repo, worktree_base_repo,
bridge, bridge: Some(bridge),
reported_activity: None, reported_activity: None,
settled: false, settled: false,
#[cfg(feature = "haiku-autoname")] #[cfg(feature = "haiku-autoname")]
@@ -695,7 +720,7 @@ impl SessionManager {
// directory blocks that branch forever (#54 review). // directory blocks that branch forever (#54 review).
worktree_path: session.worktree.as_ref().map(|w| w.path.clone()), worktree_path: session.worktree.as_ref().map(|w| w.path.clone()),
worktree_base_repo: session.worktree.as_ref().map(|w| w.base_repo.clone()), worktree_base_repo: session.worktree.as_ref().map(|w| w.base_repo.clone()),
bridge, bridge: Some(bridge),
reported_activity: None, reported_activity: None,
settled: false, settled: false,
#[cfg(feature = "haiku-autoname")] #[cfg(feature = "haiku-autoname")]
@@ -710,16 +735,89 @@ impl SessionManager {
.send(CatalogEvent::SessionAdded { session }); .send(CatalogEvent::SessionAdded { session });
adopted += 1; adopted += 1;
} }
self.register_dormant().await;
Ok(adopted) Ok(adopted)
} }
/// Forward keystrokes to a session's pane. A dead session is read-only — /// Second recovery pass (#162): register every sidecar tmux had nothing to
/// its pane is gone, so this is a no-op rather than a tmux failure the /// adopt as a [`SessionStatus::Dormant`] session.
/// client would have to interpret. ///
/// A host reboot kills the tmux server, so the first pass runs over an
/// empty list and every session would otherwise disappear from the API
/// while its sidecar sat untouched on disk. A dormant record keeps the id,
/// name, cwd, command, worktree and `created_at` — everything the sidecar
/// holds — so the operator still has a record of what was running. It has
/// no tmux session and no [`PtyBridge`] behind it: it cannot be attached
/// to, cannot be typed into, and is skipped by both watcher passes. The
/// only thing left to do with one is dismiss it, which removes the sidecar
/// so the next recovery does not bring it back.
///
/// Activity is not carried over: whatever the session was doing, it is not
/// doing it now. The rest of the sidecar is left as written.
async fn register_dormant(&self) {
let stored = match self.inner.sidecar.load_all() {
Ok(stored) => stored,
Err(e) => {
tracing::warn!(error = ?e, "recover: reading sidecars for dormant sessions failed");
return;
}
};
let mut registered = Vec::new();
{
let mut sessions = self.inner.sessions.lock().await;
for mut session in stored {
if sessions.contains_key(&session.id) {
// Adopted live by the first pass; its sidecar is on disk too.
continue;
}
let id = session.id;
session.status = SessionStatus::Dormant;
session.foreground = None;
session.activity = ActivityState::Idle;
session.activity_detail = None;
sessions.insert(
id,
SessionRecord {
session: session.clone(),
fifo_path: self.inner.fifo_dir.join(format!("{id}.fifo")),
// Still known, so a dismissal cleans the worktree up —
// the same reason recovery restores it for a live
// session (#54 review).
worktree_path: session.worktree.as_ref().map(|w| w.path.clone()),
worktree_base_repo: session.worktree.as_ref().map(|w| w.base_repo.clone()),
bridge: None,
reported_activity: None,
settled: false,
#[cfg(feature = "haiku-autoname")]
naming: false,
focused_since: None,
awaiting_suppressed: false,
},
);
registered.push(session);
}
}
if !registered.is_empty() {
tracing::info!(
dormant = registered.len(),
"registered sessions with no live tmux as dormant"
);
}
for session in registered {
let _ = self
.inner
.events
.send(CatalogEvent::SessionAdded { session });
}
}
/// Forward keystrokes to a session's pane. A dead or dormant session is
/// read-only — its pane is gone, so this is a no-op rather than a tmux
/// failure the client would have to interpret.
pub async fn send_keys(&self, id: Uuid, bytes: &[u8]) -> Result<(), SessionError> { pub async fn send_keys(&self, id: Uuid, bytes: &[u8]) -> Result<(), SessionError> {
match self.status(id).await { match self.status(id).await {
None => return Err(SessionError::NotFound(id)), None => return Err(SessionError::NotFound(id)),
Some(SessionStatus::Dead) => return Ok(()), Some(SessionStatus::Dead | SessionStatus::Dormant) => return Ok(()),
Some(SessionStatus::Running) => {} Some(SessionStatus::Running) => {}
} }
let tmux = self.inner.tmux.clone(); let tmux = self.inner.tmux.clone();
@@ -732,13 +830,15 @@ impl SessionManager {
/// Type `text` into a session's pane, with an Enter after it when /// Type `text` into a session's pane, with an Enter after it when
/// `submit` is set. /// `submit` is set.
/// ///
/// Unlike [`Self::send_keys`], a dead session is an error: the caller /// Unlike [`Self::send_keys`], a dead or dormant session is an error: the
/// asked for text to land somewhere, and a silent success would let a /// caller asked for text to land somewhere, and a silent success would let
/// script believe an agent had been told something it never heard. /// a script believe an agent had been told something it never heard.
pub async fn send_input(&self, id: Uuid, text: &str, submit: bool) -> Result<(), SessionError> { pub async fn send_input(&self, id: Uuid, text: &str, submit: bool) -> Result<(), SessionError> {
match self.status(id).await { match self.status(id).await {
None => return Err(SessionError::NotFound(id)), None => return Err(SessionError::NotFound(id)),
Some(SessionStatus::Dead) => return Err(SessionError::NotRunning(id)), Some(SessionStatus::Dead | SessionStatus::Dormant) => {
return Err(SessionError::NotRunning(id))
}
Some(SessionStatus::Running) => {} Some(SessionStatus::Running) => {}
} }
let tmux = self.inner.tmux.clone(); let tmux = self.inner.tmux.clone();
@@ -756,12 +856,12 @@ impl SessionManager {
Ok(()) Ok(())
} }
/// Resize a session's window. No-op for a dead session, as with /// Resize a session's window. No-op for a dead or dormant session, as with
/// [`Self::send_keys`]. /// [`Self::send_keys`].
pub async fn resize(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), SessionError> { pub async fn resize(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), SessionError> {
match self.status(id).await { match self.status(id).await {
None => return Err(SessionError::NotFound(id)), None => return Err(SessionError::NotFound(id)),
Some(SessionStatus::Dead) => return Ok(()), Some(SessionStatus::Dead | SessionStatus::Dormant) => return Ok(()),
Some(SessionStatus::Running) => {} Some(SessionStatus::Running) => {}
} }
let tmux = self.inner.tmux.clone(); let tmux = self.inner.tmux.clone();
@@ -770,6 +870,52 @@ impl SessionManager {
.map_err(SessionError::Tmux) .map_err(SessionError::Tmux)
} }
/// Register a client that is about to start reporting its display size,
/// and hand back the handle it reports under.
///
/// Cheap and infallible: the session it will report against is not
/// consulted until the first [`Self::set_viewport`].
pub fn attach_viewport(&self) -> ViewportId {
self.inner.next_viewport.fetch_add(1, Ordering::Relaxed)
}
/// A client reported the size it can display.
///
/// The shared tmux window is resized only when the size arbitrated across
/// every attached client actually changes — see [`crate::viewport`]. Two
/// clients of different sizes used to resize the window past each other on
/// every report, which leaves a full-screen agent repainting into rows it
/// never clears and looking, to the operator, like a pane that has stopped
/// accepting input.
pub async fn set_viewport(
&self,
id: Uuid,
viewport: ViewportId,
cols: u16,
rows: u16,
) -> Result<(), SessionError> {
let target = self
.inner
.viewports
.lock()
.await
.set(id, viewport, cols, rows);
match target {
Some((cols, rows)) => self.resize(id, cols, rows).await,
None => Ok(()),
}
}
/// Drop a detached client's claim on the window size, resizing to what the
/// clients still attached can display. Errors are the caller's to ignore:
/// a socket closing because its session died has nothing left to resize.
pub async fn release_viewport(&self, id: Uuid, viewport: ViewportId) {
let target = self.inner.viewports.lock().await.release(id, viewport);
if let Some((cols, rows)) = target {
let _ = self.resize(id, cols, rows).await;
}
}
/// Rename a session (#125): set `metadata.name`, or clear it when the /// Rename a session (#125): set `metadata.name`, or clear it when the
/// requested name is blank so the session falls back to its command. /// requested name is blank so the session falls back to its command.
/// ///
@@ -1116,7 +1262,11 @@ async fn run_activity_pass(inner: &Arc<Inner>) {
rec.session.activity_detail = None; rec.session.activity_detail = None;
} }
} }
let idle = rec.bridge.idle_for(); // Only `Running` sessions reach this pass, and every one of them
// has a bridge; a dormant session has neither pane nor bridge.
let Some(idle) = rec.bridge.as_ref().map(|b| b.idle_for()) else {
continue;
};
// #105: while a report is in force it supplies the state and // #105: while a report is in force it supplies the state and
// nothing here may overwrite it; only an unreported session is // nothing here may overwrite it; only an unreported session is
// decided by inference. // decided by inference.
@@ -1234,8 +1384,10 @@ async fn run_blocked_work_pass(inner: &Arc<Inner>) {
/// alone and stays attached to the dead record for a later forced dismissal /// alone and stays attached to the dead record for a later forced dismissal
/// (#81), which leaves it on disk too. /// (#81), which leaves it on disk too.
/// ///
/// Dead sessions do not survive a daemon restart: [`SessionManager::recover`] /// A dead session does not come back as dead across a daemon restart: its
/// adopts only what tmux still holds. /// sidecar survives, so [`SessionManager::recover`]'s second pass registers it
/// as [`SessionStatus::Dormant`] (#162) — accurate either way, since neither
/// has a pane behind it.
async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) { async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
let flipped = { let flipped = {
let mut sessions = inner.sessions.lock().await; let mut sessions = inner.sessions.lock().await;
@@ -1244,7 +1396,9 @@ async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
record.session.status = SessionStatus::Dead; record.session.status = SessionStatus::Dead;
// Release anyone attached; the scrollback survives for a later // Release anyone attached; the scrollback survives for a later
// attach. // attach.
record.bridge.seal(); if let Some(bridge) = record.bridge.as_mut() {
bridge.seal();
}
let session = record.session.clone(); let session = record.session.clone();
let worktree = record let worktree = record
.worktree_base_repo .worktree_base_repo
+186
View File
@@ -0,0 +1,186 @@
//! What size the shared tmux window should be, given every client watching it.
//!
//! A tmux window has one size, but a session can have several clients attached
//! at once — two browser panes, a browser and the TUI. Each reports the size
//! *it* can display, and applying whichever arrived last makes the window
//! flip-flop. Every flip forces a full-screen application (Claude Code, vim) to
//! repaint at a new height, and the rows it drew at the old height are never
//! cleared: the operator ends up looking at a stale input box, types into a
//! box that is not where the screen says it is, and concludes the pane is
//! stuck.
//!
//! So the window is sized to the smallest attached client on each axis — the
//! same rule tmux uses for its own multiple clients, and the only size every
//! client can render without truncation. The size last handed to tmux is
//! remembered, so a report that changes nothing costs no `tmux` fork at all.
use std::collections::HashMap;
use uuid::Uuid;
/// One attached client within a session. Allocated by
/// [`crate::session::SessionManager`] and opaque to everything else.
pub type ViewportId = u64;
/// Per-session client sizes, and what tmux was last told.
#[derive(Debug, Default)]
pub struct Viewports {
clients: HashMap<Uuid, HashMap<ViewportId, (u16, u16)>>,
applied: HashMap<Uuid, (u16, u16)>,
}
impl Viewports {
pub fn new() -> Self {
Self::default()
}
/// Record what `viewport` can display. Returns the size tmux must be set
/// to, or `None` when the window already has it.
pub fn set(
&mut self,
session: Uuid,
viewport: ViewportId,
cols: u16,
rows: u16,
) -> Option<(u16, u16)> {
self.clients
.entry(session)
.or_default()
.insert(viewport, (cols, rows));
self.settle(session)
}
/// Drop a client that has detached. Returns the size tmux must be set to
/// now that it no longer constrains the window.
///
/// The last client leaving settles nothing: the window keeps the size it
/// has rather than snapping to some remembered default no one is watching.
pub fn release(&mut self, session: Uuid, viewport: ViewportId) -> Option<(u16, u16)> {
let clients = self.clients.get_mut(&session)?;
clients.remove(&viewport);
if clients.is_empty() {
self.clients.remove(&session);
self.applied.remove(&session);
return None;
}
self.settle(session)
}
/// The smallest size every attached client can render, but only when it
/// differs from what tmux was last told.
///
/// A zero on either axis is a client that cannot display anything yet — a
/// pane still laying out, a hidden tab — and must not drag the shared
/// window down with it, so it is held but left out of the arbitration.
fn settle(&mut self, session: Uuid) -> Option<(u16, u16)> {
let clients = self.clients.get(&session)?;
let mut cols = u16::MAX;
let mut rows = u16::MAX;
for &(c, r) in clients.values() {
if c == 0 || r == 0 {
continue;
}
cols = cols.min(c);
rows = rows.min(r);
}
if cols == u16::MAX || rows == u16::MAX {
return None;
}
let target = (cols, rows);
if self.applied.get(&session) == Some(&target) {
return None;
}
self.applied.insert(session, target);
Some(target)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_first_report_settles_the_window() {
let mut v = Viewports::new();
let s = Uuid::new_v4();
assert_eq!(v.set(s, 1, 80, 24), Some((80, 24)));
}
#[test]
fn a_report_that_changes_nothing_costs_no_resize() {
let mut v = Viewports::new();
let s = Uuid::new_v4();
v.set(s, 1, 80, 24);
assert_eq!(v.set(s, 1, 80, 24), None);
assert_eq!(v.set(s, 1, 80, 24), None);
}
/// The bug this module exists for: two clients of different sizes used to
/// resize the window past each other on every report.
#[test]
fn two_clients_settle_on_the_smaller_instead_of_fighting() {
let mut v = Viewports::new();
let s = Uuid::new_v4();
assert_eq!(v.set(s, 1, 53, 46), Some((53, 46)));
assert_eq!(v.set(s, 2, 53, 20), Some((53, 20)));
// Whatever order they re-report in, the answer stays the smaller one.
assert_eq!(v.set(s, 1, 53, 46), None);
assert_eq!(v.set(s, 2, 53, 20), None);
assert_eq!(v.set(s, 1, 53, 46), None);
}
#[test]
fn each_axis_is_arbitrated_on_its_own() {
let mut v = Viewports::new();
let s = Uuid::new_v4();
v.set(s, 1, 200, 20);
assert_eq!(v.set(s, 2, 53, 46), Some((53, 20)));
}
#[test]
fn a_zero_sized_client_does_not_shrink_the_window() {
let mut v = Viewports::new();
let s = Uuid::new_v4();
assert_eq!(v.set(s, 1, 80, 24), Some((80, 24)));
assert_eq!(v.set(s, 2, 0, 0), None);
assert_eq!(v.set(s, 2, 0, 30), None);
}
#[test]
fn releasing_the_small_client_gives_the_window_back() {
let mut v = Viewports::new();
let s = Uuid::new_v4();
v.set(s, 1, 80, 46);
v.set(s, 2, 80, 20);
assert_eq!(v.release(s, 2), Some((80, 46)));
}
#[test]
fn releasing_the_last_client_settles_nothing() {
let mut v = Viewports::new();
let s = Uuid::new_v4();
v.set(s, 1, 80, 24);
assert_eq!(v.release(s, 1), None);
// ...and the session is forgotten, so a fresh attach settles again.
assert_eq!(v.set(s, 2, 80, 24), Some((80, 24)));
}
#[test]
fn releasing_an_unknown_viewport_is_harmless() {
let mut v = Viewports::new();
let s = Uuid::new_v4();
assert_eq!(v.release(s, 7), None);
v.set(s, 1, 80, 24);
assert_eq!(v.release(s, 7), None);
}
#[test]
fn sessions_do_not_arbitrate_against_each_other() {
let mut v = Viewports::new();
let a = Uuid::new_v4();
let b = Uuid::new_v4();
assert_eq!(v.set(a, 1, 80, 24), Some((80, 24)));
assert_eq!(v.set(b, 2, 53, 20), Some((53, 20)));
assert_eq!(v.set(a, 1, 80, 24), None);
}
}
+1
View File
@@ -38,6 +38,7 @@ async fn spawn_daemon_ws() -> Harness {
"/home/test", "/home/test",
Vec::new(), Vec::new(),
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()), std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
maestro_config::DEFAULT_LEADER,
)); ));
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await .await
+1
View File
@@ -27,6 +27,7 @@ async fn spawn() -> Harness {
"/home/test", "/home/test",
presets, presets,
Arc::new(maestro_daemon::projects::ProjectScanner::disabled()), Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
maestro_config::DEFAULT_LEADER,
)); ));
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await .await
+41
View File
@@ -36,6 +36,14 @@ async fn spawn(hostname: &str) -> Harness {
async fn spawn_with( async fn spawn_with(
hostname: &str, hostname: &str,
projects: std::sync::Arc<maestro_daemon::projects::ProjectScanner>, projects: std::sync::Arc<maestro_daemon::projects::ProjectScanner>,
) -> Harness {
spawn_with_leader(hostname, projects, maestro_config::DEFAULT_LEADER).await
}
async fn spawn_with_leader(
hostname: &str,
projects: std::sync::Arc<maestro_daemon::projects::ProjectScanner>,
leader: &str,
) -> Harness { ) -> Harness {
let fx = DaemonFixture::new(); let fx = DaemonFixture::new();
let manager = fx.manager.clone(); let manager = fx.manager.clone();
@@ -48,6 +56,7 @@ async fn spawn_with(
"/home/test", "/home/test",
presets, presets,
projects, projects,
leader,
)); ));
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await .await
@@ -91,6 +100,38 @@ async fn info_returns_daemon_identity() {
assert!(resp["version"].is_string()); assert!(resp["version"].is_string());
} }
/// #159: the web client reads its leader off `/api/info` instead of a
/// hardcoded constant, so the payload has to carry the configured one — not
/// the default dressed up as one.
#[tokio::test]
async fn info_advertises_the_configured_leader() {
let h = spawn_with_leader(
"naps-laptop",
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
"ctrl-b",
)
.await;
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/info"))
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(resp["leader"], "ctrl-b");
}
#[tokio::test]
async fn info_reports_the_default_leader_when_unconfigured() {
let h = spawn("naps-laptop").await;
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/info"))
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(resp["leader"], "ctrl-a");
}
#[tokio::test] #[tokio::test]
async fn info_reports_build_and_instance() { async fn info_reports_build_and_instance() {
// The status indicator needs a release to name and a process to compare // The status indicator needs a release to name and a process to compare
@@ -555,3 +555,63 @@ async fn spawn_overrides_client_supplied_maestro_variables() {
Some(&"http://127.0.0.1:7070".to_string()) Some(&"http://127.0.0.1:7070".to_string())
); );
} }
/// Two clients attached to one session used to resize the shared window past
/// each other on every report they made. Every flip repaints a full-screen
/// agent at a new height and orphans the rows it drew at the old one, which
/// reads to the operator as a pane that has stopped taking input.
#[tokio::test]
async fn clients_of_different_sizes_do_not_resize_the_window_past_each_other() {
let fx = Fx::new();
let s = fx.manager.create(fx.request("bash")).await.expect("create");
let web = fx.manager.attach_viewport();
let tui = fx.manager.attach_viewport();
fx.manager.set_viewport(s.id, web, 53, 46).await.unwrap();
fx.manager.set_viewport(s.id, tui, 53, 20).await.unwrap();
// Both keep reporting, as a browser's ResizeObserver and the TUI's own
// redraw loop do. Neither report changes the arbitrated size.
for _ in 0..5 {
fx.manager.set_viewport(s.id, web, 53, 46).await.unwrap();
fx.manager.set_viewport(s.id, tui, 53, 20).await.unwrap();
}
let resizes: Vec<_> = fx
.fake
.calls()
.into_iter()
.filter_map(|c| match c {
Call::ResizeWindow { cols, rows, .. } => Some((cols, rows)),
_ => None,
})
.collect();
assert_eq!(
resizes,
vec![(53, 46), (53, 20)],
"the window was resized more than the arbitrated size actually changed"
);
}
/// The smaller client leaving hands the window back to the one still attached.
#[tokio::test]
async fn releasing_a_client_resizes_to_what_is_left() {
let fx = Fx::new();
let s = fx.manager.create(fx.request("bash")).await.expect("create");
let web = fx.manager.attach_viewport();
let tui = fx.manager.attach_viewport();
fx.manager.set_viewport(s.id, web, 80, 46).await.unwrap();
fx.manager.set_viewport(s.id, tui, 80, 20).await.unwrap();
fx.manager.release_viewport(s.id, tui).await;
let resizes: Vec<_> = fx
.fake
.calls()
.into_iter()
.filter_map(|c| match c {
Call::ResizeWindow { cols, rows, .. } => Some((cols, rows)),
_ => None,
})
.collect();
assert_eq!(resizes, vec![(80, 46), (80, 20), (80, 46)]);
}
@@ -140,6 +140,140 @@ async fn recover_adopts_existing_tmux_sessions() {
assert_eq!(snap[0].command, "sleep 30"); assert_eq!(snap[0].command, "sleep 30");
} }
/// #162: a host reboot kills the tmux server, so recovery finds nothing to
/// adopt while every sidecar is still on disk. Those sessions come back as
/// `Dormant` — a record of what was running — rather than vanishing from the
/// API and leaving the operator with nothing.
#[tokio::test]
async fn recover_registers_orphaned_sidecars_as_dormant() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let mut metadata = HashMap::new();
metadata.insert(
maestro_protocol::NAME_KEY.to_string(),
serde_json::Value::String("after-reboot".into()),
);
let s = fx
.manager
.create(CreateSessionRequest {
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata,
worktree: None,
})
.await
.expect("create");
// The reboot: the tmux server is gone, the sidecar is untouched.
fx.tmux.tmux.kill_server().expect("kill tmux server");
let revived = revive(&fx);
let adopted = revived.recover().await.expect("recover");
assert_eq!(adopted, 0, "no live tmux session left to adopt");
let snap = revived.snapshot().await;
assert_eq!(snap.len(), 1, "the sidecar's session is still listed");
assert_eq!(snap[0].id, s.id);
assert_eq!(snap[0].status, SessionStatus::Dormant);
assert_eq!(snap[0].command, "sleep 30");
assert_eq!(snap[0].cwd, s.cwd);
assert_eq!(snap[0].created_at, s.created_at);
assert_eq!(
snap[0]
.metadata
.get(maestro_protocol::NAME_KEY)
.and_then(|v| v.as_str()),
Some("after-reboot"),
);
}
/// #162: a session tmux still holds is adopted `Running` by the first pass and
/// must not be re-registered as dormant by the second — the sidecar is on disk
/// for both.
#[tokio::test]
async fn recover_does_not_mark_adopted_sessions_dormant() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let live = fx
.manager
.create(CreateSessionRequest {
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.expect("create live");
let orphan = fx
.manager
.create(CreateSessionRequest {
cwd: cwd.path().to_path_buf(),
command: "sleep 31".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.expect("create orphan");
// Only the second session loses its pane.
fx.tmux.tmux.kill_session(orphan.id).expect("kill session");
let revived = revive(&fx);
assert_eq!(revived.recover().await.expect("recover"), 1);
let mut snap = revived.snapshot().await;
snap.sort_by_key(|s| s.command.clone());
assert_eq!(snap.len(), 2);
assert_eq!(snap[0].id, live.id);
assert_eq!(snap[0].status, SessionStatus::Running);
assert_eq!(snap[1].id, orphan.id);
assert_eq!(snap[1].status, SessionStatus::Dormant);
}
/// #162: dismissing a dormant session is the one thing that can be done to it
/// — no tmux call to fail, and the sidecar goes with it so the next recovery
/// does not resurrect it.
#[tokio::test]
async fn dormant_session_can_be_dismissed() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let s = fx
.manager
.create(CreateSessionRequest {
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.expect("create");
fx.tmux.tmux.kill_server().expect("kill tmux server");
let revived = revive(&fx);
revived.recover().await.expect("recover");
revived.kill(s.id, false).await.expect("dismiss");
assert!(!revived.has(s.id).await);
assert!(!fx.sidecar_dir().join(format!("{}.json", s.id)).exists());
}
/// A second `SessionManager` over the same dirs and the same tmux server —
/// what a daemon restart looks like from inside a test.
fn revive(fx: &DaemonFixture) -> maestro_daemon::SessionManager {
maestro_daemon::SessionManager::new(
Arc::new(fx.tmux.tmux.clone()),
maestro_daemon::sidecar::SidecarStore::new(fx.sidecar_dir()),
maestro_daemon::worktree::WorktreeManager::new(None),
fx.fifo_dir(),
)
}
/// Regression for NAP-253: the very first bytes a freshly-spawned command /// Regression for NAP-253: the very first bytes a freshly-spawned command
/// writes must reach the replay buffer. Before the fix, `pipe-pane` was a /// writes must reach the replay buffer. Before the fix, `pipe-pane` was a
/// separate tmux invocation that ran after `new-session` + an async FIFO /// separate tmux invocation that ran after `new-session` + an async FIFO
+1
View File
@@ -26,6 +26,7 @@ async fn spawn() -> Harness {
"/home/test", "/home/test",
maestro_config::SpawnSection::default().resolve().unwrap(), maestro_config::SpawnSection::default().resolve().unwrap(),
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()), std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
maestro_config::DEFAULT_LEADER,
)); ));
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await .await
+65 -1
View File
@@ -123,10 +123,38 @@ fn epoch() -> DateTime<Utc> {
DateTime::from_timestamp(0, 0).expect("unix epoch is a valid timestamp") DateTime::from_timestamp(0, 0).expect("unix epoch is a valid timestamp")
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] /// Lifecycle of a session as the daemon knows it.
///
/// Deserializes leniently, like [`ActivityState`] (#162): an unknown spelling
/// from a newer daemon reads as [`SessionStatus::Dead`] instead of failing the
/// whole `Session`. `Dead` is the safe degrade — an unrecognised session is
/// one this client must not treat as having a live pane to type into.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum SessionStatus { pub enum SessionStatus {
Running, Running,
Dead, Dead,
/// Known from its sidecar, with no tmux session behind it (#162). The
/// daemon registers these at recovery time for every sidecar tmux had
/// nothing to adopt — after a host reboot, that is every session there
/// was. There is no pane and no PTY bridge: a dormant session is a record
/// of what was running, listed so the operator still has it, and the only
/// thing that can be done to it is dismissing it.
Dormant,
}
impl<'de> Deserialize<'de> for SessionStatus {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
Ok(match s.as_str() {
"Running" => Self::Running,
"Dormant" => Self::Dormant,
// Known or not, any other spelling degrades to the inert variant.
_ => Self::Dead,
})
}
} }
/// Inferred session activity, orthogonal to [`SessionStatus`] (NAP-340). The /// Inferred session activity, orthogonal to [`SessionStatus`] (NAP-340). The
@@ -489,8 +517,11 @@ impl ApiError {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpawnPreset { pub struct SpawnPreset {
pub id: String, pub id: String,
#[serde(default)]
pub label: String, pub label: String,
pub command: String, pub command: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resume_command: Option<String>,
} }
/// Build provenance of the running daemon: the commit it was compiled /// Build provenance of the running daemon: the commit it was compiled
@@ -554,6 +585,12 @@ pub struct DaemonInfo {
/// network blip, even when the build did not change. /// network blip, even when the build did not change.
#[serde(default)] #[serde(default)]
pub instance: String, pub instance: String,
/// Canonical leader key the operator configured (`[tui].leader`, default
/// `ctrl-a`), served so the web client binds the same chord the TUI does
/// instead of its own hardcoded constant (#159). Empty from a daemon too
/// old to serve it, which clients read as "use your default".
#[serde(default)]
pub leader: String,
} }
/// Payload of `GET /api/names?count=N` (#53) — a batch of fresh /// Payload of `GET /api/names?count=N` (#53) — a batch of fresh
@@ -1053,6 +1090,33 @@ mod tests {
serde_json::to_value(SessionStatus::Dead).unwrap(), serde_json::to_value(SessionStatus::Dead).unwrap(),
serde_json::json!("Dead") serde_json::json!("Dead")
); );
assert_eq!(
serde_json::to_value(SessionStatus::Dormant).unwrap(),
serde_json::json!("Dormant")
);
}
// #162: a session with no tmux behind it still rides the wire whole.
#[test]
fn dormant_session_round_trips() {
let mut s = sample_session();
s.status = SessionStatus::Dormant;
assert_eq!(s, round_trip(&s));
}
// #162: a `SessionStatus` this client does not know must degrade to the
// inert variant, not fail the whole `Session` the way a derived
// `Deserialize` would.
#[test]
fn unknown_session_status_degrades_to_dead() {
assert_eq!(
serde_json::from_value::<SessionStatus>(serde_json::json!("Hibernating")).unwrap(),
SessionStatus::Dead
);
let mut value = serde_json::to_value(sample_session()).unwrap();
value["status"] = serde_json::json!("Hibernating");
let s: Session = serde_json::from_value(value).expect("session survives unknown status");
assert_eq!(s.status, SessionStatus::Dead);
} }
#[test] #[test]
+21 -2
View File
@@ -194,7 +194,9 @@ pub enum BarOutcome {
pub fn order_sessions_for_bar(sessions: &[Session]) -> Vec<&Session> { pub fn order_sessions_for_bar(sessions: &[Session]) -> Vec<&Session> {
let mut ordered: Vec<&Session> = sessions.iter().collect(); let mut ordered: Vec<&Session> = sessions.iter().collect();
ordered.sort_by(|a, b| { ordered.sort_by(|a, b| {
let dead = |s: &Session| u8::from(s.status == SessionStatus::Dead); // docs/COMMAND-BAR.md: anything with no live pane — dead or dormant
// (#162) — sorts after the sessions you could actually attach to.
let dead = |s: &Session| u8::from(s.status != SessionStatus::Running);
dead(a) dead(a)
.cmp(&dead(b)) .cmp(&dead(b))
.then_with(|| { .then_with(|| {
@@ -319,7 +321,7 @@ impl CommandBar {
.map(|s| BarEntry { .map(|s| BarEntry {
candidate: MatchCandidate::Session(session_candidate(s, home_dir)), candidate: MatchCandidate::Session(session_candidate(s, home_dir)),
target: BarTarget::Session(s.id), target: BarTarget::Session(s.id),
dead: s.status == SessionStatus::Dead, dead: s.status != SessionStatus::Running,
chords: Vec::new(), chords: Vec::new(),
}) })
.collect(); .collect();
@@ -889,6 +891,23 @@ mod tests {
assert!(bar.rows()[1].dead); assert!(bar.rows()[1].dead);
} }
/// #162: docs/COMMAND-BAR.md ranks by whether you could attach, not by
/// which way the pane went away — a dormant session sorts with the dead.
#[test]
fn dormant_sessions_sort_after_live_ones_however_recent() {
let mut dormant = named(1, "dormant");
dormant.status = SessionStatus::Dormant;
dormant.last_focused_at = Some(at(99));
let live = named(2, "live");
let bar = CommandBar::new(&[dormant, live], &[], "/home/me");
assert_eq!(
ids(bar.rows()),
vec![Uuid::from_u128(2), Uuid::from_u128(1)]
);
assert!(bar.rows()[1].dead);
}
#[test] #[test]
fn typing_ranks_through_the_matcher_and_returns_ranges() { fn typing_ranks_through_the_matcher_and_returns_ranges() {
let mut bar = CommandBar::new( let mut bar = CommandBar::new(
+1 -1
View File
@@ -114,7 +114,7 @@ impl TuiConfig {
TuiTheme::Nord => Palette::NORD, TuiTheme::Nord => Palette::NORD,
TuiTheme::Dracula => Palette::DRACULA, TuiTheme::Dracula => Palette::DRACULA,
}; };
let leader_str = section.leader.as_deref().unwrap_or("ctrl-a"); let leader_str = section.leader();
let leader = InputKey::parse(leader_str) let leader = InputKey::parse(leader_str)
.map_err(|_| TuiConfigError::BadLeader(leader_str.to_string()))?; .map_err(|_| TuiConfigError::BadLeader(leader_str.to_string()))?;
let chord_timeout = Duration::from_millis(section.chord_timeout_ms.unwrap_or(1000)); let chord_timeout = Duration::from_millis(section.chord_timeout_ms.unwrap_or(1000));
+1
View File
@@ -250,6 +250,7 @@ mod tests {
dirty: false, dirty: false,
}, },
instance: instance.into(), instance: instance.into(),
leader: maestro_config::DEFAULT_LEADER.into(),
} }
} }
+4
View File
@@ -1705,6 +1705,7 @@ mod tests {
id: "aider".into(), id: "aider".into(),
label: "Aider".into(), label: "Aider".into(),
command: "aider".into(), command: "aider".into(),
resume_command: None,
}]); }]);
assert_eq!( assert_eq!(
draft.command(), draft.command(),
@@ -1727,11 +1728,13 @@ mod tests {
id: "claude".into(), id: "claude".into(),
label: "Claude".into(), label: "Claude".into(),
command: "claude".into(), command: "claude".into(),
resume_command: None,
}, },
SpawnPreset { SpawnPreset {
id: "aider".into(), id: "aider".into(),
label: "Aider".into(), label: "Aider".into(),
command: "aider".into(), command: "aider".into(),
resume_command: None,
}, },
]); ]);
assert_eq!(draft.command(), "aider"); assert_eq!(draft.command(), "aider");
@@ -1751,6 +1754,7 @@ mod tests {
id: "claude".into(), id: "claude".into(),
label: "Claude".into(), label: "Claude".into(),
command: "claude".into(), command: "claude".into(),
resume_command: None,
}]); }]);
assert!(draft.is_custom()); assert!(draft.is_custom());
assert_eq!(draft.command(), "bash -l"); assert_eq!(draft.command(), "bash -l");
+1
View File
@@ -183,6 +183,7 @@ mod tests {
id: id.into(), id: id.into(),
label: id.into(), label: id.into(),
command: command.into(), command: command.into(),
resume_command: None,
} }
} }
+39
View File
@@ -268,6 +268,8 @@ impl Sidebar {
fn name_row(&self, s: &Session, width: usize, focused: bool) -> Vec<Span<'static>> { fn name_row(&self, s: &Session, width: usize, focused: bool) -> Vec<Span<'static>> {
let glyph = match (s.status, self.attached.contains(&s.id)) { let glyph = match (s.status, self.attached.contains(&s.id)) {
(SessionStatus::Dead, _) => "✗", (SessionStatus::Dead, _) => "✗",
// #162: known from its sidecar, nothing running behind it.
(SessionStatus::Dormant, _) => "◌",
(SessionStatus::Running, true) => "●", (SessionStatus::Running, true) => "●",
(SessionStatus::Running, false) => "○", (SessionStatus::Running, false) => "○",
}; };
@@ -322,6 +324,7 @@ impl Sidebar {
// on line 1; the kill confirm names the specifics. // on line 1; the kill confirm names the specifics.
let blocked = s.blocked_work.filter(|b| !b.is_clear()); let blocked = s.blocked_work.filter(|b| !b.is_clear());
let dead = s.status == SessionStatus::Dead; let dead = s.status == SessionStatus::Dead;
let dormant = s.status == SessionStatus::Dormant;
let highlighted = self.highlight == Some(SidebarItem::Session(s.id)); let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
let fill = self.card_fill(s, focused); let fill = self.card_fill(s, focused);
let base = card_base(fill, focused); let base = card_base(fill, focused);
@@ -361,6 +364,9 @@ impl Sidebar {
if dead { if dead {
tail.push(Span::styled(" exited", muted)); tail.push(Span::styled(" exited", muted));
} }
if dormant {
tail.push(Span::styled(" dormant", muted));
}
// docs/SIDEBAR.md: the card names the program, never its arguments. // docs/SIDEBAR.md: the card names the program, never its arguments.
let command = Span::styled(format!(" {}", command_program(&s.command)), muted); let command = Span::styled(format!(" {}", command_program(&s.command)), muted);
@@ -1068,6 +1074,39 @@ mod tests {
); );
} }
/// #162, docs/SIDEBAR.md: a dormant session is known but not running —
/// its own glyph on line 1 and the muted `dormant` fragment on line 2, so
/// it never reads as something that exited.
#[test]
fn dormant_session_reads_as_known_but_not_running() {
let mut s = session_named(1, "claude");
s.status = SessionStatus::Dormant;
s.activity_since = Utc::now() - Duration::seconds(5);
let sb = Sidebar {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 28, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
assert!(
all[idx].contains('\u{25cc}'),
"expected the dormant glyph in: {:?}",
all[idx]
);
assert!(
all[idx + 1].contains("dormant"),
"expected dormant fragment in: {:?}",
all[idx + 1]
);
assert!(
!all[idx + 1].contains("exited"),
"a dormant session never reads as exited: {:?}",
all[idx + 1]
);
}
/// docs/SIDEBAR.md: line 2 names the program, never its arguments. /// docs/SIDEBAR.md: line 2 names the program, never its arguments.
#[test] #[test]
fn meta_line_drops_the_commands_arguments() { fn meta_line_drops_the_commands_arguments() {
+15 -9
View File
@@ -45,6 +45,12 @@ mirroring the TUI's. `ctrl-a` is select-all in a browser; the web
`preventDefault`s it, since selecting all of a terminal pane does nothing `preventDefault`s it, since selecting all of a terminal pane does nothing
useful. useful.
One knob drives both clients: the daemon serves `[tui].leader` as `leader` on
`GET /api/info`, and the web binds that instead of its own constant (#159). A
daemon too old to send the field, or an unreachable `/api/info`, leaves the web
on `ctrl-a`. The section is named `[tui]` for history — it is the leader for
every client, not the TUI's alone.
The leader itself needs the same kind of way back: holding it consumes every The leader itself needs the same kind of way back: holding it consumes every
press, on both clients, even before anything is bound behind it. Pressing it press, on both clients, even before anything is bound behind it. Pressing it
twice — `ctrl-a ctrl-a` by default — sends one literal leader byte to the twice — `ctrl-a ctrl-a` by default — sends one literal leader byte to the
@@ -117,9 +123,9 @@ client opening `/api/attach/:id`, and a client reporting focus explicitly at
`POST /api/sessions/:id/focus` when it refocuses a pane that is already open. `POST /api/sessions/:id/focus` when it refocuses a pane that is already open.
**A session that has never been focused sorts after every session that has**, **A session that has never been focused sorts after every session that has**,
and among those, most recently created first. Dead sessions sort after live and among those, most recently created first. Sessions with no live pane —
ones regardless of recency: a session you cannot attach to is not where you `Dead`, and `Dormant` (#162) — sort after live ones regardless of recency: a
just were. session you cannot attach to is not where you just were.
Under `>` an empty query lists every command in registry order. Under `~` it Under `>` an empty query lists every command in registry order. Under `~` it
lists recent dirs, most recent first, then the project list. lists recent dirs, most recent first, then the project list.
@@ -152,12 +158,12 @@ searchable — the query line keeps working, now over those actions.
ambiguous. ambiguous.
- Escape, or backspace at an empty query, returns to the result list **with the - Escape, or backspace at an empty query, returns to the result list **with the
previous query intact**. Drilling in and backing out is free. previous query intact**. Drilling in and backing out is free.
- Each action raises the flow it already has: kill goes through the existing - Each action raises the flow it already has: kill through the confirmation
confirm-and-blockers dialog (`docs/WORKTREES.md`), rename through the both clients ask for (`docs/SIDEBAR.md`) and the blockers dialog behind it
existing prompt pre-filled with the session's own name (`docs/WORKTREES.md`), rename through the existing prompt pre-filled with the
(`docs/SIDEBAR.md`), spawn similar through the spawn form seeded from the session's own name (`docs/SIDEBAR.md`), spawn similar through the spawn form
session's directory and command. The bar picks the action; it never seeded from the session's directory and command. The bar picks the action; it
reimplements one. never reimplements one.
No actions beyond those three, and no action across more than one session. No actions beyond those three, and no action across more than one session.
+2 -1
View File
@@ -136,7 +136,8 @@ cover every v1 action — only override what you want to change:
```toml ```toml
[tui] [tui]
theme = "dark" # see the theme list below theme = "dark" # see the theme list below
leader = "ctrl-a" # one byte that escapes Insert mode leader = "ctrl-a" # one byte that escapes Insert mode; the web reads
# the same setting off /api/info
chord_timeout_ms = 1000 # how long to hold a partial chord like `ctrl-w` chord_timeout_ms = 1000 # how long to hold a partial chord like `ctrl-w`
[tui.keymap.normal] [tui.keymap.normal]
+20
View File
@@ -68,6 +68,26 @@ resolves, so a blocked client is never a trapped one. It clears itself the
moment a probe succeeds — there is no "retry" button, because the client is moment a probe succeeds — there is no "retry" button, because the client is
already retrying. already retrying.
## What survives, and what comes back dormant
A daemon restart leaves tmux alone, so recovery re-adopts every
`maestro-<uuid>` session tmux still holds and the operator sees no change
beyond the reconnect above.
A **host reboot** is different: it kills the tmux server, so there is nothing
to adopt while every session's sidecar is still on disk. Recovery makes a
second pass over those sidecars and registers each as a `Dormant` session
(#162) — id, name, cwd, command, worktree and `created_at` intact. A dormant
session has no pane and no PTY bridge: it cannot be attached to, typed into or
resized, and neither watcher pass touches it. It is a record of what was
running, and the only thing left to do with one is dismiss it, which removes
the sidecar so the next recovery does not bring it back.
`SessionStatus` is a wire type, so it deserializes leniently on both clients:
a spelling this build does not know reads as `Dead` rather than failing the
whole session. Clients render a dormant session as known-but-not-running —
see `docs/SIDEBAR.md` for the card and `docs/COMMAND-BAR.md` for how it ranks.
## Timing ## Timing
- **Grace window** — 1200 ms between the socket dropping and the overlay - **Grace window** — 1200 ms between the socket dropping and the overlay
+26 -5
View File
@@ -56,7 +56,7 @@ itself is not.
Line 2 reads left to right: Line 2 reads left to right:
``` ```
[<duration>] [<detail>] <command> [<diff stat> | exited] [<duration>] [<detail>] <command> [<diff stat> | exited | dormant]
``` ```
Its minimum content is therefore just the command. Its minimum content is therefore just the command.
@@ -104,8 +104,14 @@ fragment; it no longer decides whether line 2 exists.
while an agent-reported state is in force (see Precedence): when the report while an agent-reported state is in force (see Precedence): when the report
lapses at its TTL or is replaced, the detail goes with it. The detail lapses at its TTL or is replaced, the detail goes with it. The detail
truncates with `…` ahead of the trailing fragments — a long detail never truncates with `…` ahead of the trailing fragments — a long detail never
pushes `<command>`, `<blocked work>`, or `exited` off the line, and neither pushes `<command>`, `<blocked work>`, `exited` or `dormant` off the line, and
client ever lets it wrap. neither client ever lets it wrap.
`dormant` (#162) is the counterpart of `exited` for a session the daemon knows
only from its sidecar: recovery found no tmux session behind it, so it has no
pane, no scrollback, and nothing to attach to. It is not an exit — nothing was
observed ending — so the two fragments never both show. The TUI marks the same
state on line 1 with `◌`, next to `✗` for exited and `●`/`○` for running.
`<diff stat>` is the blocked-work breakdown (#83) in diff notation, replacing `<diff stat>` is the blocked-work breakdown (#83) in diff notation, replacing
the spelled-out `⚠ 3 uncommitted files, 2 unpushed commits`. It renders only the spelled-out `⚠ 3 uncommitted files, 2 unpushed commits`. It renders only
@@ -209,7 +215,7 @@ the cursor's fill while the pane holds the keyboard, and the web's ring on a
highlighted **folder** row — a folder previews nothing, so it has no fill to highlighted **folder** row — a folder previews nothing, so it has no fill to
step up to. step up to.
The duration text inherits its state's colour; the exited and command The duration text inherits its state's colour; the exited, dormant and command
fragments use the surface's `muted` role, as do the diff stat's `~`, `?` and fragments use the surface's `muted` role, as do the diff stat's `~`, `?` and
`↑` fragments. Only `+` and `-` step outside the surface's muted role, and they `↑` fragments. Only `+` and `-` step outside the surface's muted role, and they
take the `active` and `error` roles of the same surface. take the `active` and `error` roles of the same surface.
@@ -316,7 +322,7 @@ Everything else is the same key on both clients:
| `Tab` | hand the keyboard to the pane | | `Tab` | hand the keyboard to the pane |
| `c` | collapse or expand the highlighted folder | | `c` | collapse or expand the highlighted folder |
| `r` | rename the highlighted session | | `r` | rename the highlighted session |
| `x` | kill the highlighted session | | `x` | kill the highlighted session (confirms) |
| `n` | open the spawn form | | `n` | open the spawn form |
| `N` | spawn with every default resolved | | `N` | spawn with every default resolved |
| `?` | show the shortcut overlay | | `?` | show the shortcut overlay |
@@ -324,3 +330,18 @@ Everything else is the same key on both clients:
The TUI binds these through its keymap, so a user can rebind any of them; the The TUI binds these through its keymap, so a user can rebind any of them; the
web client's are fixed. A binding added to one client belongs in this table and web client's are fixed. A binding added to one client belongs in this table and
in both help overlays. in both help overlays.
## Killing a session
**Every kill confirms first, in both clients** (#160). A kill is destructive
and unattended agents live in these sessions, so neither client sends
`DELETE /api/sessions/:id` off a single press. The TUI raises
`OverlayKind::KillConfirm` (`[y]`/`[n]`); the web raises the same dialog its
`✕` button and the command bar's action page both use. Cancel holds the focus
in the web's dialog, so nothing kills on a stray Enter.
The confirmation names what the card last saw in the way, when anything was —
the daemon will refuse an unforced kill over such a worktree, and saying so
before the attempt is cheaper than a refusal. What comes back from that refusal
is a second, separate prompt offering `force=true`; `docs/WORKTREES.md` owns
what a forced kill leaves behind.
+9 -1
View File
@@ -65,7 +65,7 @@ async function flush(): Promise<void> {
} }
describe("kill from the action page", () => { describe("kill from the action page", () => {
it("goes through the daemon, without a first confirmation", async () => { it("goes through the same confirmation the sidebar raises (#160)", async () => {
const calls: string[] = []; const calls: string[] = [];
window.fetch = ((url: string, init?: RequestInit) => { window.fetch = ((url: string, init?: RequestInit) => {
calls.push(`${init?.method ?? "GET"} ${url}`); calls.push(`${init?.method ?? "GET"} ${url}`);
@@ -75,6 +75,12 @@ describe("kill from the action page", () => {
mount(s); mount(s);
raise(KILL_SESSION_EVENT, s); raise(KILL_SESSION_EVENT, s);
await flush(); await flush();
expect(document.querySelector(".kill-dialog")?.getAttribute("aria-label")).toBe(
"Kill amber-falcon",
);
expect(calls.filter((c) => c.startsWith("DELETE"))).toHaveLength(0);
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
await flush();
expect(calls).toContain(`DELETE /api/sessions/${s.id}`); expect(calls).toContain(`DELETE /api/sessions/${s.id}`);
expect(document.querySelector(".kill-dialog")).toBeNull(); expect(document.querySelector(".kill-dialog")).toBeNull();
}); });
@@ -101,6 +107,8 @@ describe("kill from the action page", () => {
mount(s); mount(s);
raise(KILL_SESSION_EVENT, s); raise(KILL_SESSION_EVENT, s);
await flush(); await flush();
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
await flush();
const dialog = document.querySelector(".kill-dialog"); const dialog = document.querySelector(".kill-dialog");
expect(dialog?.getAttribute("aria-label")).toBe("Kill amber-falcon"); expect(dialog?.getAttribute("aria-label")).toBe("Kill amber-falcon");
expect(dialog?.textContent).toContain("1 untracked file"); expect(dialog?.textContent).toContain("1 untracked file");
+53 -1
View File
@@ -98,7 +98,7 @@ describe("blocked-work indicator (#83)", () => {
expect(rowFor(el, unprobed.id)?.querySelector(".leaf-blocked")).toBeNull(); expect(rowFor(el, unprobed.id)?.querySelector(".leaf-blocked")).toBeNull();
}); });
it("kills straight away, without a first confirmation", async () => { it("confirms before an unguarded kill (#160)", async () => {
const calls: string[] = []; const calls: string[] = [];
window.fetch = ((url: string, init?: RequestInit) => { window.fetch = ((url: string, init?: RequestInit) => {
calls.push(`${init?.method ?? "GET"} ${url}`); calls.push(`${init?.method ?? "GET"} ${url}`);
@@ -108,10 +108,45 @@ describe("blocked-work indicator (#83)", () => {
const el = renderSidebar(storeWith([plain])).el; const el = renderSidebar(storeWith([plain])).el;
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click(); rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
await flush(); await flush();
const dialog = document.querySelector(".kill-dialog");
expect(dialog?.getAttribute("aria-label")).toBe("Kill claude");
expect(calls.filter((c) => c.startsWith("DELETE"))).toHaveLength(0);
dialog?.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
await flush();
expect(calls).toContain(`DELETE /api/sessions/${plain.id}`); expect(calls).toContain(`DELETE /api/sessions/${plain.id}`);
expect(document.querySelector(".kill-dialog")).toBeNull(); expect(document.querySelector(".kill-dialog")).toBeNull();
}); });
it("kills nothing when the confirmation is cancelled (#160)", async () => {
const calls: string[] = [];
window.fetch = ((url: string, init?: RequestInit) => {
calls.push(`${init?.method ?? "GET"} ${url}`);
return Promise.resolve(new Response(null, { status: 204 }));
}) as typeof fetch;
const plain = session({ id: "aaaaaaaa-0000-0000-0000-000000000007" });
const el = renderSidebar(storeWith([plain])).el;
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
await flush();
document.querySelector<HTMLButtonElement>(".kill-dialog-cancel")?.click();
await flush();
expect(document.querySelector(".kill-dialog")).toBeNull();
expect(calls.filter((c) => c.startsWith("DELETE"))).toHaveLength(0);
});
it("names the blockers in the confirmation, before the daemon refuses (#160)", async () => {
window.fetch = (() => Promise.reject(new Error("never called"))) as typeof fetch;
const blocked = session({
id: "aaaaaaaa-0000-0000-0000-000000000008",
blocked_work: { untracked_files: 1 },
});
const el = renderSidebar(storeWith([blocked])).el;
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
await flush();
const dialog = document.querySelector(".kill-dialog");
expect(dialog?.textContent).toContain("1 untracked file");
expect(dialog?.textContent).toContain("refused unless it is forced");
});
it("offers the override when the daemon refuses a guarded kill", async () => { it("offers the override when the daemon refuses a guarded kill", async () => {
const calls: string[] = []; const calls: string[] = [];
window.fetch = ((url: string, init?: RequestInit) => { window.fetch = ((url: string, init?: RequestInit) => {
@@ -138,6 +173,8 @@ describe("blocked-work indicator (#83)", () => {
const el = renderSidebar(storeWith([blocked])).el; const el = renderSidebar(storeWith([blocked])).el;
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click(); rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
await flush(); await flush();
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
await flush();
const dialog = document.querySelector(".kill-dialog"); const dialog = document.querySelector(".kill-dialog");
expect(dialog?.textContent).toContain("1 untracked file"); expect(dialog?.textContent).toContain("1 untracked file");
dialog?.querySelector<HTMLButtonElement>(".kill-dialog-force")?.click(); dialog?.querySelector<HTMLButtonElement>(".kill-dialog-force")?.click();
@@ -166,6 +203,8 @@ describe("blocked-work indicator (#83)", () => {
const el = renderSidebar(storeWith([blocked])).el; const el = renderSidebar(storeWith([blocked])).el;
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click(); rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
await flush(); await flush();
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
await flush();
document.querySelector<HTMLButtonElement>(".kill-dialog-cancel")?.click(); document.querySelector<HTMLButtonElement>(".kill-dialog-cancel")?.click();
expect(document.querySelector(".kill-dialog")).toBeNull(); expect(document.querySelector(".kill-dialog")).toBeNull();
expect(calls.filter((c) => c.includes("force=true"))).toHaveLength(0); expect(calls.filter((c) => c.includes("force=true"))).toHaveLength(0);
@@ -375,6 +414,19 @@ describe("session cards (#107)", () => {
expect(line2?.textContent).toContain("exited"); expect(line2?.textContent).toContain("exited");
}); });
// #162: a session recovered with no tmux behind it is known but not running,
// and never reads as something that exited.
it("shows dormant on line 2 for dormant sessions", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000118",
status: "Dormant",
});
const el = renderSidebar(storeWith([s])).el;
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
expect(line2?.textContent).toContain("dormant");
expect(line2?.textContent).not.toContain("exited");
});
it("carries the state word only in tooltips and aria labels, never as text", () => { it("carries the state word only in tooltips and aria labels, never as text", () => {
const s = session({ const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000019", id: "aaaaaaaa-0000-0000-0000-000000000019",
+35 -24
View File
@@ -1,6 +1,6 @@
import { escapeHtml } from "./html.js"; import { escapeHtml } from "./html.js";
import { ApiRequestError, getDaemonInfo, killSession, renameSession } from "./api.js"; import { ApiRequestError, getDaemonInfo, killSession, renameSession } from "./api.js";
import { openForceKillDialog } from "./killDialog.js"; import { openForceKillDialog, openKillConfirmDialog } from "./killDialog.js";
import { openContextMenu } from "./contextMenu.js"; import { openContextMenu } from "./contextMenu.js";
import { toggleHelp } from "./help.js"; import { toggleHelp } from "./help.js";
import { CommandSet, defineCommands } from "./commands.js"; import { CommandSet, defineCommands } from "./commands.js";
@@ -113,6 +113,9 @@ function activityRailMarkup(slug: string, stateWord: string): string {
function metaLine(s: Session, now: number): string { function metaLine(s: Session, now: number): string {
const dead = s.status === "Dead"; const dead = s.status === "Dead";
// #162: known from its sidecar with nothing running behind it — a distinct
// fragment, since it never exited.
const dormant = s.status === "Dormant";
const blockers = sessionBlockers(s); const blockers = sessionBlockers(s);
const slug = activityColorSlug(s.activity ?? "Idle"); const slug = activityColorSlug(s.activity ?? "Idle");
const held = activityHeldMs(s.activity_since, now); const held = activityHeldMs(s.activity_since, now);
@@ -127,6 +130,7 @@ function metaLine(s: Session, now: number): string {
commandFragment(s.command), commandFragment(s.command),
blockers ? blockedFragment(blockers) : "", blockers ? blockedFragment(blockers) : "",
dead ? `<span class="card-flag">exited</span>` : "", dead ? `<span class="card-flag">exited</span>` : "",
dormant ? `<span class="card-flag">dormant</span>` : "",
]; ];
return `<span class="card-line2" data-activity="${slug}">${fragments.join("")}</span>`; return `<span class="card-line2" data-activity="${slug}">${fragments.join("")}</span>`;
} }
@@ -230,7 +234,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
killBtn.addEventListener("click", (ev) => { killBtn.addEventListener("click", (ev) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
void requestKill(cur, sessionName(cur)); requestKill(cur, sessionName(cur));
}); });
row.addEventListener("click", () => { row.addEventListener("click", () => {
document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: cur.id })); document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: cur.id }));
@@ -248,7 +252,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
{ {
label: "Kill session", label: "Kill session",
danger: true, danger: true,
onSelect: () => void requestKill(cur, sessionName(cur)), onSelect: () => requestKill(cur, sessionName(cur)),
}, },
]); ]);
}); });
@@ -293,26 +297,33 @@ function treeSignature(node: TreeNode): string {
} }
/** /**
* Kill on the first click (#81): the daemon is the one that decides whether a * Kill behind a confirmation (#160): a kill is destructive and unattended
* session may go, and it refuses over a worktree holding unsaved work. Only * agents live in these sessions, so the web asks first, the way the TUI always
* that refusal is worth a prompt, and it needs one `confirm()` cannot give — * has. The daemon decides the rest — it refuses a kill over a worktree holding
* a forced kill. Anything else surfaces as an alert. * unsaved work, and that refusal needs a prompt the confirmation cannot give,
* a forced kill. Anything else surfaces in the dialog that asked.
*/ */
async function requestKill(s: Session, name: string): Promise<void> { function requestKill(s: Session, name: string): void {
try { openKillConfirmDialog({
await killSession(s.id); name,
} catch (e) { blockers: sessionBlockers(s),
if (e instanceof ApiRequestError && e.code === "kill_guarded") { onKill: async () => {
openForceKillDialog({ try {
name, await killSession(s.id);
blockers: e.unsavedWork ?? sessionBlockers(s), } catch (e) {
reason: e.detail, if (e instanceof ApiRequestError && e.code === "kill_guarded") {
onForce: () => killSession(s.id, true), openForceKillDialog({
}); name,
return; blockers: e.unsavedWork ?? sessionBlockers(s),
} reason: e.detail,
alert(`Failed to kill: ${(e as Error).message}`); onForce: () => killSession(s.id, true),
} });
return;
}
throw e;
}
},
});
} }
/** /**
@@ -719,7 +730,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
}, },
"list.kill": () => { "list.kill": () => {
const hit = highlightedSession(); const hit = highlightedSession();
if (hit) void requestKill(hit.session, sessionName(hit.session)); if (hit) requestKill(hit.session, sessionName(hit.session));
}, },
"list.focusPane": () => document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT)), "list.focusPane": () => document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT)),
}), }),
@@ -782,7 +793,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
// than by row. // than by row.
const killHandler = (ev: Event) => { const killHandler = (ev: Event) => {
const s = (ev as CustomEvent<Session>).detail; const s = (ev as CustomEvent<Session>).detail;
void requestKill(s, sessionName(s)); requestKill(s, sessionName(s));
}; };
const renameHandler = (ev: Event) => { const renameHandler = (ev: Event) => {
const s = (ev as CustomEvent<Session>).detail; const s = (ev as CustomEvent<Session>).detail;
+14
View File
@@ -99,6 +99,20 @@ describe("empty-query ordering", () => {
]); ]);
}); });
// #162: docs/COMMAND-BAR.md ranks by whether you could attach, not by which
// way the pane went away.
it("sorts dormant sessions after live ones, like dead ones", () => {
const dormantRecent = session("dormant", {
status: "Dormant",
last_focused_at: "2026-06-01T00:00:00Z",
});
const liveStale = session("live", { last_focused_at: "2020-01-01T00:00:00Z" });
expect(orderSessionsForBar([dormantRecent, liveStale]).map((s) => s.id)).toEqual([
"live",
"dormant",
]);
});
it("treats a null last_focused_at as never focused", () => { it("treats a null last_focused_at as never focused", () => {
const nulled = session("nulled", { last_focused_at: null }); const nulled = session("nulled", { last_focused_at: null });
const stamped = session("stamped", { last_focused_at: "2020-01-01T00:00:00Z" }); const stamped = session("stamped", { last_focused_at: "2020-01-01T00:00:00Z" });
+5 -3
View File
@@ -142,8 +142,10 @@ function createdAt(s: Session): number {
* matcher's last tie-break preserves. */ * matcher's last tie-break preserves. */
export function orderSessionsForBar(sessions: readonly Session[]): Session[] { export function orderSessionsForBar(sessions: readonly Session[]): Session[] {
return [...sessions].sort((a, b) => { return [...sessions].sort((a, b) => {
const deadA = a.status === "Dead" ? 1 : 0; // docs/COMMAND-BAR.md: anything with no live pane — dead or dormant (#162)
const deadB = b.status === "Dead" ? 1 : 0; // — sorts after the sessions you could actually attach to.
const deadA = a.status === "Running" ? 0 : 1;
const deadB = b.status === "Running" ? 0 : 1;
if (deadA !== deadB) return deadA - deadB; if (deadA !== deadB) return deadA - deadB;
const focusA = focusedAt(a); const focusA = focusedAt(a);
@@ -350,7 +352,7 @@ function renderRow(result: BarResult, index: number): HTMLElement {
row.dataset.kind = candidate.kind; row.dataset.kind = candidate.kind;
if (candidate.kind === "session") { if (candidate.kind === "session") {
row.dataset.sessionId = candidate.session.id; row.dataset.sessionId = candidate.session.id;
if (candidate.session.status === "Dead") row.classList.add("is-dead"); if (candidate.session.status !== "Running") row.classList.add("is-dead");
} else if (isActionCandidate(candidate)) { } else if (isActionCandidate(candidate)) {
row.dataset.actionId = candidate.action.id; row.dataset.actionId = candidate.action.id;
} else if (candidate.kind === "command") { } else if (candidate.kind === "command") {
+32
View File
@@ -4,6 +4,7 @@ import { describe, expect, it, vi } from "vitest";
import { import {
COMMAND_SPECS, COMMAND_SPECS,
CommandSet, CommandSet,
commandSpecs,
DEFAULT_LEADER, DEFAULT_LEADER,
canonicalizeKey, canonicalizeKey,
controlByte, controlByte,
@@ -165,6 +166,37 @@ describe("the leader", () => {
expect(DEFAULT_LEADER).toBe("ctrl-a"); expect(DEFAULT_LEADER).toBe("ctrl-a");
}); });
// #159: the leader is the operator's, served on `/api/info`. Every chord
// behind it is built from it, so a rebound leader carries its escape hatches
// with it instead of leaving them on a stale ctrl-a.
it("builds the chords behind it from the configured leader", () => {
const byId = new Map(commandSpecs("ctrl-b").map((s) => [s.id, s]));
expect(byId.get("pane.literalCtrlK")?.keys).toEqual(["ctrl-b ctrl-k"]);
expect(byId.get("pane.literalLeader")?.keys).toEqual(["ctrl-b ctrl-b"]);
// Commands that do not hang off the leader are untouched.
expect(byId.get("bar.open")?.keys).toEqual(["ctrl-k"]);
});
it("dispatches a configured leader's chord and not the default's", () => {
const literalCtrlK = vi.fn();
const set = new CommandSet(defineCommands({ "pane.literalCtrlK": literalCtrlK }, "ctrl-b"), {
leader: "ctrl-b",
});
set.handle(key("b", { ctrlKey: true }));
expect(set.handle(key("k", { ctrlKey: true }))).toBe(true);
expect(literalCtrlK).toHaveBeenCalledTimes(1);
// The old leader is an ordinary key again: it holds nothing, and the chord
// that used to follow it runs nothing.
set.handle(key("a", { ctrlKey: true }));
set.handle(key("k", { ctrlKey: true }));
expect(literalCtrlK).toHaveBeenCalledTimes(1);
});
it("sends the configured leader's own byte for the doubled chord", () => {
expect(controlByte("ctrl-b")).toBe("\x02");
});
it("holds the leader as a partial chord instead of selecting the page", () => { it("holds the leader as a partial chord instead of selecting the page", () => {
const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }), { const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }), {
leader: DEFAULT_LEADER, leader: DEFAULT_LEADER,
+31 -9
View File
@@ -32,13 +32,26 @@ export interface Command extends CommandSpec {
run: () => void; run: () => void;
} }
/** Default leader, mirroring the TUI's `[tui].leader`. `ctrl-a` is select-all /** Leader used when the daemon does not name one — an older daemon, or an
* in a browser, so a set that owns the leader `preventDefault`s it. */ * unreachable `/api/info`. Mirrors the TUI's `[tui].leader` default. `ctrl-a`
* is select-all in a browser, so a set that owns the leader
* `preventDefault`s it. */
export const DEFAULT_LEADER = "ctrl-a"; export const DEFAULT_LEADER = "ctrl-a";
/** Every command the web client has. The single list this issue exists to /** Every command the web client has, for one leader. The single list: labels,
* create: labels, chords and grouping live here and nowhere else. */ * chords and grouping live here and nowhere else. Chords behind the leader are
export const COMMAND_SPECS: readonly CommandSpec[] = [ * built from it (#159), so the configured leader — served on `/api/info` as
* `leader` — is what the overlay renders and the sets dispatch. */
export function commandSpecs(leader: string = DEFAULT_LEADER): readonly CommandSpec[] {
return SPECS.map(({ leaderKeys, ...spec }) =>
leaderKeys ? { ...spec, keys: leaderKeys(leader) } : spec,
);
}
/** A spec whose chords are fixed, or a spec that builds them from the leader. */
type SpecSource = CommandSpec & { leaderKeys?: (leader: string) => string[] };
const SPECS: readonly SpecSource[] = [
{ {
id: "spawn.open", id: "spawn.open",
label: "Open the spawn form", label: "Open the spawn form",
@@ -65,6 +78,7 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [
label: "Send a literal Ctrl-K to the pane", label: "Send a literal Ctrl-K to the pane",
group: "Pane", group: "Pane",
keys: [`${DEFAULT_LEADER} ctrl-k`], keys: [`${DEFAULT_LEADER} ctrl-k`],
leaderKeys: (leader) => [`${leader} ctrl-k`],
scope: "global", scope: "global",
}, },
{ {
@@ -72,6 +86,7 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [
label: "Send a literal leader key to the pane", label: "Send a literal leader key to the pane",
group: "Pane", group: "Pane",
keys: [`${DEFAULT_LEADER} ${DEFAULT_LEADER}`], keys: [`${DEFAULT_LEADER} ${DEFAULT_LEADER}`],
leaderKeys: (leader) => [`${leader} ${leader}`],
scope: "global", scope: "global",
}, },
{ {
@@ -160,13 +175,20 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [
}, },
]; ];
const SPEC_BY_ID = new Map(COMMAND_SPECS.map((s) => [s.id, s])); /** The command list under the default leader. Iteration order everywhere else
* (the help overlay, the bar) comes from here. */
export const COMMAND_SPECS: readonly CommandSpec[] = commandSpecs();
/** Attach handlers to specs, by id. Throws on an unknown id, so a typo is a /** Attach handlers to specs, by id. Throws on an unknown id, so a typo is a
* test failure rather than a key that silently does nothing. */ * test failure rather than a key that silently does nothing. `leader` picks
export function defineCommands(handlers: Record<string, () => void>): Command[] { * the chords behind the leader; omit it for a set that has none. */
export function defineCommands(
handlers: Record<string, () => void>,
leader: string = DEFAULT_LEADER,
): Command[] {
const byId = new Map(commandSpecs(leader).map((s) => [s.id, s]));
return Object.entries(handlers).map(([id, run]) => { return Object.entries(handlers).map(([id, run]) => {
const spec = SPEC_BY_ID.get(id); const spec = byId.get(id);
if (!spec) throw new Error(`unknown command id: ${id}`); if (!spec) throw new Error(`unknown command id: ${id}`);
return { ...spec, run }; return { ...spec, run };
}); });
+28 -1
View File
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom // @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { keyCaps, publishedCommands } from "./commands.js"; import { defineCommands, keyCaps, publishCommands, publishedCommands } from "./commands.js";
import { closeHelp, groupedCommands, installHelpKeybind, isHelpOpen, openHelp } from "./help.js"; import { closeHelp, groupedCommands, installHelpKeybind, isHelpOpen, openHelp } from "./help.js";
beforeEach(() => { beforeEach(() => {
@@ -44,6 +44,33 @@ describe("help overlay", () => {
.toEqual(publishedCommands().map((c) => c.id)); .toEqual(publishedCommands().map((c) => c.id));
}); });
// #159: the leader comes from the daemon, so the overlay must render the
// configured chord — a hardcoded `Ctrl A` is the visible half of the bug.
it("renders the configured leader's chord, not a hardcoded Ctrl A", () => {
const bound = defineCommands(
{ "pane.literalCtrlK": () => {}, "pane.literalLeader": () => {} },
"ctrl-b",
);
publishCommands(bound);
try {
openHelp();
const keysOf = (id: string) =>
[
...(document.querySelector(`.help-row[data-command-id="${id}"]`) as HTMLElement)
.querySelectorAll("kbd"),
]
.map((k) => k.textContent)
.join(" ");
expect(keysOf("pane.literalCtrlK")).toBe("Ctrl B then Ctrl K");
expect(keysOf("pane.literalLeader")).toBe("Ctrl B then Ctrl B");
} finally {
// Leave the registry as the rest of the suite expects it.
publishCommands(
defineCommands({ "pane.literalCtrlK": () => {}, "pane.literalLeader": () => {} }),
);
}
});
it("opens on ? and closes on a second ?", () => { it("opens on ? and closes on a second ?", () => {
press("?", { shiftKey: true }); press("?", { shiftKey: true });
expect(isHelpOpen()).toBe(true); expect(isHelpOpen()).toBe(true);
+83 -22
View File
@@ -1,14 +1,44 @@
/** /**
* Forced-kill prompt (#81). * The two kill prompts (#81, #160).
* *
* The daemon refuses to kill a session whose worktree still holds uncommitted * A kill is destructive and unattended agents live in these sessions, so both
* or unpushed work. `confirm()` can only ask the same question twice, so the * clients ask before one goes through — the TUI's `KillConfirm`, and
* refusal gets its own dialog: it names what is in the way and offers the * `openKillConfirmDialog` here (#160). Where the session's worktree still
* override the daemon's `force=true` already implements. A kill the daemon * holds uncommitted or unpushed work, the confirmation says so up front, the
* accepts never reaches here — there is nothing to warn about. * same way the TUI's does.
*
* The daemon decides the rest: it refuses a kill over such a worktree, and
* that refusal needs an answer the confirmation cannot give — a forced kill.
* `openForceKillDialog` is that second prompt. A kill the daemon accepts never
* reaches it.
*/ */
import { formatInTheWay, type InTheWay } from "./types.js"; import { formatInTheWay, type InTheWay } from "./types.js";
/** What both prompts need: a target to name and a button that acts on it. */
interface KillDialogSpec {
/** Session name, as the sidebar shows it. */
name: string;
/** Sentence under the title. */
body: string;
/** Label on the acting button. */
confirmLabel: string;
/** Extra class on the acting button, so tests and styles can find it. */
confirmClass: string;
/** Run it. A rejection is rendered in the dialog, which stays open so the
* press can be retried. */
onConfirm: () => Promise<void>;
}
export interface KillConfirmPrompt {
name: string;
/** What the card last saw in the way, when anything was. Shown before the
* attempt, so "this will be refused" is not a surprise. */
blockers: InTheWay | null;
/** Run the plain kill. Opening a further dialog from here is allowed: the
* confirmation only closes itself if it is still the one on screen. */
onKill: () => Promise<void>;
}
export interface ForceKillPrompt { export interface ForceKillPrompt {
/** Session name, as the sidebar shows it. */ /** Session name, as the sidebar shows it. */
name: string; name: string;
@@ -30,9 +60,9 @@ export function closeForceKillDialog(): void {
open = null; open = null;
} }
/** Mount the prompt. Opening while one is up replaces it, so a second refusal /** Mount one of the prompts. Opening while one is up replaces it, so the
* can never stack two dialogs. */ * confirmation and a refusal that follows it can never stack. */
export function openForceKillDialog(p: ForceKillPrompt): HTMLElement { function mount(p: KillDialogSpec): HTMLElement {
closeForceKillDialog(); closeForceKillDialog();
const el = document.createElement("div"); const el = document.createElement("div");
@@ -50,9 +80,7 @@ export function openForceKillDialog(p: ForceKillPrompt): HTMLElement {
const body = document.createElement("p"); const body = document.createElement("p");
body.className = "kill-dialog-body"; body.className = "kill-dialog-body";
body.textContent = p.blockers body.textContent = p.body;
? `Its worktree still has ${formatInTheWay(p.blockers)}. Forcing the kill removes the session and leaves the worktree on disk.`
: `${p.reason} Forcing the kill removes the session and leaves the worktree on disk.`;
const error = document.createElement("p"); const error = document.createElement("p");
error.className = "kill-dialog-error"; error.className = "kill-dialog-error";
@@ -65,22 +93,27 @@ export function openForceKillDialog(p: ForceKillPrompt): HTMLElement {
cancel.className = "btn btn-ghost kill-dialog-cancel"; cancel.className = "btn btn-ghost kill-dialog-cancel";
cancel.textContent = "Cancel"; cancel.textContent = "Cancel";
cancel.addEventListener("click", closeForceKillDialog); cancel.addEventListener("click", closeForceKillDialog);
const force = document.createElement("button"); const confirm = document.createElement("button");
force.type = "button"; confirm.type = "button";
force.className = "btn btn-danger kill-dialog-force"; confirm.className = `btn btn-danger ${p.confirmClass}`;
force.textContent = "Force kill"; confirm.textContent = p.confirmLabel;
force.addEventListener("click", () => { confirm.addEventListener("click", () => {
force.disabled = true; confirm.disabled = true;
void p void p
.onForce() .onConfirm()
.then(closeForceKillDialog) .then(() => {
// A handler that opened the refusal prompt has already replaced this
// dialog; closing here would take that one down with it.
if (open?.el === el) closeForceKillDialog();
})
.catch((e: unknown) => { .catch((e: unknown) => {
force.disabled = false; if (open?.el !== el) return;
confirm.disabled = false;
error.hidden = false; error.hidden = false;
error.textContent = `Failed to kill: ${(e as Error).message}`; error.textContent = `Failed to kill: ${(e as Error).message}`;
}); });
}); });
actions.append(cancel, force); actions.append(cancel, confirm);
panel.append(title, body, error, actions); panel.append(title, body, error, actions);
@@ -99,6 +132,34 @@ export function openForceKillDialog(p: ForceKillPrompt): HTMLElement {
}; };
window.addEventListener("keydown", onKeydown, true); window.addEventListener("keydown", onKeydown, true);
open = { el, onKeydown }; open = { el, onKeydown };
// Cancel takes the focus in both prompts: a kill is never one keystroke
// away from whatever asked for the dialog.
cancel.focus(); cancel.focus();
return el; return el;
} }
/** Ask before a kill the daemon has not seen yet (#160). */
export function openKillConfirmDialog(p: KillConfirmPrompt): HTMLElement {
return mount({
name: p.name,
body: p.blockers
? `Its worktree still has ${formatInTheWay(p.blockers)}. The kill will be refused unless it is forced.`
: "The session and everything running in it go away. Its worktree stays on disk.",
confirmLabel: "Kill",
confirmClass: "kill-dialog-kill",
onConfirm: p.onKill,
});
}
/** Offer the override after the daemon refuses a guarded kill (#81). */
export function openForceKillDialog(p: ForceKillPrompt): HTMLElement {
return mount({
name: p.name,
body: p.blockers
? `Its worktree still has ${formatInTheWay(p.blockers)}. Forcing the kill removes the session and leaves the worktree on disk.`
: `${p.reason} Forcing the kill removes the session and leaves the worktree on disk.`,
confirmLabel: "Force kill",
confirmClass: "kill-dialog-force",
onConfirm: p.onForce,
});
}
+54 -28
View File
@@ -87,9 +87,15 @@ const store = new CatalogStore();
// Absolute paths are a fine fallback until it lands. // Absolute paths are a fine fallback until it lands.
let homeDir: string | undefined; let homeDir: string | undefined;
void getDaemonInfo() void getDaemonInfo()
.then((info) => (homeDir = info.home_dir)) .then((info) => {
homeDir = info.home_dir;
// The leader is the operator's, not this module's (#159): a daemon that
// names one rebinds the chords hanging off it, and the `?` overlay and the
// command bar re-render from the rebuilt set.
if (info.leader) useLeader(info.leader);
})
.catch(() => { .catch(() => {
/* absolute paths are a fine fallback */ /* absolute paths and the default leader are fine fallbacks */
}); });
// The daemon's identity and liveness: names the release in the sidebar's status // The daemon's identity and liveness: names the release in the sidebar's status
@@ -439,32 +445,52 @@ const spawnKeys = new CommandSet(
// terminal owns every plain keystroke, so nothing unmodified could ever get // terminal owns every plain keystroke, so nothing unmodified could ever get
// the focus back out of it. // the focus back out of it.
// //
// This set also owns the leader (`ctrl-a`, the TUI's default): it holds the // This set also owns the leader (`[tui].leader`, `ctrl-a` by default, served
// key as a partial chord and takes select-all off it, so the escape hatch // on `/api/info`): it holds the key as a partial chord and takes select-all off
// docs/COMMAND-BAR.md describes has somewhere to land (#149). // it, so the escape hatch docs/COMMAND-BAR.md describes has somewhere to land
const focusKeys = new CommandSet( // (#149).
defineCommands({ let currentLeader = DEFAULT_LEADER;
"focus.toggle": () => {
if (sidebar.hasListFocus()) { function buildFocusKeys(leader: string): CommandSet {
pool.focusTerminal(); return new CommandSet(
} else { defineCommands(
drawer.open(); // no-op on desktop; on mobile the list is off-canvas {
sidebar.focusList(); "focus.toggle": () => {
} if (sidebar.hasListFocus()) {
}, pool.focusTerminal();
// The escape hatch: binding ctrl-k takes readline's kill-to-end-of-line } else {
// away from every pane, and leader ctrl-k gives it back as the literal drawer.open(); // no-op on desktop; on mobile the list is off-canvas
// 0x0B byte (docs/COMMAND-BAR.md). It resolves in this set, ahead of the sidebar.focusList();
// bar's, so the chord never opens the bar. }
"pane.literalCtrlK": () => pool.sendToFocused("\x0b"), },
// The leader itself, pressed twice, tmux-style: sends one literal leader // The escape hatch: binding ctrl-k takes readline's
// byte and clears the pending chord instead of resolving as a dead chord // kill-to-end-of-line away from every pane, and leader ctrl-k gives it
// (docs/COMMAND-BAR.md, #158). Built from `DEFAULT_LEADER`, not a // back as the literal 0x0B byte (docs/COMMAND-BAR.md). It resolves in
// hardcoded byte, so a different leader would send its own. // this set, ahead of the bar's, so the chord never opens the bar.
"pane.literalLeader": () => pool.sendToFocused(controlByte(DEFAULT_LEADER)), "pane.literalCtrlK": () => pool.sendToFocused("\x0b"),
}), // The leader itself, pressed twice, tmux-style: sends one literal
{ leader: DEFAULT_LEADER }, // leader byte and clears the pending chord instead of resolving as a
); // dead chord (docs/COMMAND-BAR.md, #158). Built from the leader, not a
// hardcoded byte, so a rebound leader sends its own.
"pane.literalLeader": () => pool.sendToFocused(controlByte(leader)),
},
leader,
),
{ leader },
);
}
let focusKeys = buildFocusKeys(DEFAULT_LEADER);
/** Rebind the leader-owning set to the daemon's leader. Re-registering the
* same command ids replaces them, so the help overlay and the command bar —
* both of which render the published registry when they open — show the
* configured chord rather than the default. */
function useLeader(leader: string): void {
if (leader === currentLeader) return;
currentLeader = leader;
focusKeys = buildFocusKeys(leader);
}
// Ctrl+K opens the command bar (docs/COMMAND-BAR.md). It is a registry command // Ctrl+K opens the command bar (docs/COMMAND-BAR.md). It is a registry command
// like any other, so the `?` overlay lists it without a second list. // like any other, so the `?` overlay lists it without a second list.
+3 -1
View File
@@ -143,7 +143,7 @@ describe("sidebar keyboard navigation", () => {
field!.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); field!.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
}); });
it("x kills the highlighted session", async () => { it("x kills the highlighted session, once confirmed (#160)", async () => {
const { el } = twoFolders(); const { el } = twoFolders();
const killed: string[] = []; const killed: string[] = [];
window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
@@ -153,6 +153,8 @@ describe("sidebar keyboard navigation", () => {
press(tree(el), "j"); press(tree(el), "j");
press(tree(el), "x"); press(tree(el), "x");
expect(killed.some((r) => r.startsWith("DELETE"))).toBe(false);
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
await Promise.resolve(); await Promise.resolve();
expect(killed.some((r) => r.startsWith("DELETE") && r.includes(A))).toBe(true); expect(killed.some((r) => r.startsWith("DELETE") && r.includes(A))).toBe(true);
}); });
+8 -1
View File
@@ -6,7 +6,10 @@ import { escapeHtml } from "./html.js";
export type Uuid = string; export type Uuid = string;
export type IsoTimestamp = string; export type IsoTimestamp = string;
export type SessionStatus = "Running" | "Dead"; /** `Dormant` (#162) is a session the daemon knows only from its sidecar —
* recovered after the tmux server died under it, so there is no pane behind it
* and nothing to attach to. Listed, never live. */
export type SessionStatus = "Running" | "Dead" | "Dormant";
/** Daemon-inferred activity dimension, orthogonal to `SessionStatus` (NAP-340). /** Daemon-inferred activity dimension, orthogonal to `SessionStatus` (NAP-340).
* Absent on payloads from daemons that predate the field. `Error` is only set * Absent on payloads from daemons that predate the field. `Error` is only set
@@ -98,6 +101,10 @@ export interface DaemonInfo {
* value across a reconnect means the daemon restarted. Absent on older * value across a reconnect means the daemon restarted. Absent on older
* daemons, which the restart flow treats as "cannot tell". */ * daemons, which the restart flow treats as "cannot tell". */
instance?: string; instance?: string;
/** Canonical leader key the operator configured (`[tui].leader`, default
* `ctrl-a`), so the web binds the same chord the TUI does (#159). Absent or
* empty on an older daemon; the client then keeps its own default. */
leader?: string;
} }
/** Build provenance of the running daemon: what the status indicator names in /** Build provenance of the running daemon: what the status indicator names in