Compare commits

..
Author SHA1 Message Date
Miguel PalhasandClaude Sonnet 5 f0c272e0b1 feat(maestro): copy terminal selection to clipboard
ci / web (push) Successful in 28s
ci / rust (push) Failing after 2m6s
ci / flake (push) Successful in 2m14s
xterm.js has no built-in copy-on-select; mirror onSelectionChange to
navigator.clipboard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 14:33:24 +01:00
Miguel Palhas 624f6f19c8 Merge remote-tracking branch 'origin/fermata'
ci / web (push) Failing after 13s
ci / flake (push) Successful in 3m12s
ci / rust (push) Failing after 3m26s
2026-09-04 13:32:05 +01:00
Miguel Palhas 08161ae042 Merge remote-tracking branch 'origin/main' into fermata 2026-09-04 13:30:19 +01:00
Miguel PalhasandClaude Sonnet 5 1e3c7b294d fix(maestro): send plain Escape to the pane, move focus.toggle behind the leader
Escape unfocusing the pane made it impossible to send a literal Esc into
the attached session. Removes pane.focusList and its Escape binding, and
rebinds focus.toggle from ctrl-shift-h (claimed by the browser's History
shortcut) to leader-h, consistent with the other leader escape hatches.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 13:30:14 +01:00
Miguel PalhasandClaude Sonnet 5 49ab7e9c81 fix(maestro): suffix branch name on new-branch worktree collision
ci / rust (push) Failing after 3m26s
ci / web (push) Successful in 43s
ci / flake (push) Successful in 3m54s
git worktree add -b fails outright when the branch already exists, even
if the target directory is free. A random fast-forward handle commonly
collides with a branch an earlier, already-removed worktree left behind.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 13:29:49 +01:00
Miguel PalhasandClaude Sonnet 5 0cc25d7e1c fix(maestro): equalize split widths after spawning a session
ci / rust (push) Successful in 9m12s
ci / web (push) Successful in 33s
ci / flake (push) Successful in 2m37s
splitLeaf always halved just the target pane, so repeated splits left
one pane at 50% and the rest squeezed into shrinking fractions instead
of equal columns.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 13:29:03 +01:00
Miguel PalhasandClaude Opus 5 706942ddd7 feat(maestro): install activity hooks for codex too
ci / web (push) Successful in 51s
ci / flake (push) Successful in 3m52s
ci / rust (push) Successful in 6m47s
The hook installer only ever wrote ~/.claude/settings.json, so a codex
session reported no activity and never posted its first prompt — leaving
it on the daemon's generated handle with no haiku name.

Adds a Target enum (claude-code, codex) over the hook file's location and
event set. One script and one marker serve both; codex swaps Notification
for PermissionRequest and has no StopFailure, so it never reports Error.
Both agents spell the prompt event UserPromptSubmit and put the text in
the payload's `prompt`, so autoname needs no change.

Codex will not run a hook it has not been told to trust; `maestro hooks
install` says so, and the approval stays the operator's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 10:44:14 +01:00
Miguel Palhas 52877abe29 fix(maestro): focus the kill button in kill dialogs
ci / web (push) Successful in 37s
ci / flake (push) Successful in 2m50s
ci / rust (push) Successful in 5m46s
Enter now confirms the kill the operator just asked for; Escape and the scrim still cancel.
2026-09-04 08:24:32 +01:00
68 changed files with 726 additions and 4120 deletions
-1
View File
@@ -14,7 +14,6 @@ maestro daemon tmux wrapper + session API + SPA on 127.0.0.1:7070
├── DELETE /api/sessions/:id kill
├── POST /api/sessions/:id/input type text into a session
├── POST /api/sessions/:id/prompt an agent reports its first prompt (#131)
├── POST /api/sessions/:id/resume respawn a dormant session, same id (#166)
├── GET /api/info hostname, version, home dir, spawn presets
├── GET /api/dirs directory listing (spawn autocomplete)
├── GET /api/recent-dirs spawn history
+23 -11
View File
@@ -96,20 +96,32 @@ pub async fn dispatch(
}
/// `maestro hooks install` (#106): write the shipped hook script under the
/// user's data dir and register it in their Claude Code settings. Idempotent
/// and foreign-hook-preserving — the guarantees live in [`maestro_hooks`].
/// user's data dir and register it in every agent's hook file. Idempotent and
/// foreign-hook-preserving — the guarantees live in [`maestro_hooks`]. One
/// agent failing is reported and does not stop the others.
#[allow(clippy::result_large_err)]
fn run_hooks_install() -> Result<(), CliError> {
let home = dirs::home_dir().ok_or(CliError::NoHome)?;
let changed = maestro_hooks::install_for_home(&home)?;
if changed {
println!(
"installed claude-code activity hook: {} → {}",
maestro_hooks::script_path(&home).display(),
maestro_hooks::settings_path(&home).display()
);
} else {
println!("claude-code activity hook already up to date");
let script = maestro_hooks::script_path(&home);
for (target, result) in maestro_hooks::install_for_home(&home) {
let agent = target.label();
let settings = target.settings_path(&home);
match result {
Ok(true) => {
println!(
"installed {agent} activity hook: {} → {}",
script.display(),
settings.display()
);
if target == maestro_hooks::Target::Codex {
// Codex will not run a hook it has not been told to
// trust, and that approval is the operator's to give.
println!(" codex will ask you to trust this hook the next time it starts");
}
}
Ok(false) => println!("{agent} activity hook already up to date"),
Err(e) => eprintln!("{agent} activity hook not installed: {e}"),
}
}
Ok(())
}
-2
View File
@@ -188,7 +188,6 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
let status = match s.status {
SessionStatus::Running => "running",
SessionStatus::Dead => "dead",
SessionStatus::Dormant => "dormant",
};
let activity = match s.activity {
ActivityState::Active => "active",
@@ -253,7 +252,6 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
agent_session_id: None,
}
}
@@ -28,7 +28,6 @@ async fn info_carries_version_and_presets() {
id: "claude".into(),
label: "Claude".into(),
command: "claude".into(),
resume_command: None,
}],
..Default::default()
});
-140
View File
@@ -129,80 +129,6 @@ pub struct DaemonSection {
/// `Some(0)` disables history entirely (no log written, no recents read).
#[serde(default)]
pub spawn_history_days: Option<u32>,
/// What recovery does with sessions that outlived their tmux server —
/// after a host reboot, or any restart that finds a sidecar with no pane
/// (#167). Default [`ResumeOnBoot::Dormant`].
#[serde(default, deserialize_with = "deserialize_resume_on_boot")]
pub resume_on_boot: Option<ResumeOnBoot>,
}
impl DaemonSection {
/// The configured `[daemon].resume_on_boot`, or the safe default:
/// recovered-but-dead sessions wait for the operator.
pub fn resume_on_boot(&self) -> ResumeOnBoot {
self.resume_on_boot.unwrap_or_default()
}
}
/// `[daemon].resume_on_boot` — what recovery does with a session whose tmux
/// pane is gone but whose sidecar is still on disk (#167).
///
/// Resuming agents unattended seconds after a reboot is a foot-gun: they run
/// with whatever flags they were spawned with, with nobody watching. So the
/// default waits for a human, and `auto` is opted into per host.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ResumeOnBoot {
/// Register each one `Dormant` and wait for the operator to resume it.
#[default]
Dormant,
/// Resume each one during recovery, on the same path a client's
/// `POST /api/sessions/:id/resume` takes.
Auto,
/// Prune the sidecars instead: a reboot is a clean slate.
Never,
}
/// Every `[daemon].resume_on_boot` name and its variant, in documentation
/// order.
pub const RESUME_ON_BOOT_POLICIES: &[(&str, ResumeOnBoot)] = &[
("dormant", ResumeOnBoot::Dormant),
("auto", ResumeOnBoot::Auto),
("never", ResumeOnBoot::Never),
];
impl ResumeOnBoot {
/// The name this policy is spelled with in the config file and in logs.
pub fn as_str(&self) -> &'static str {
match self {
ResumeOnBoot::Dormant => "dormant",
ResumeOnBoot::Auto => "auto",
ResumeOnBoot::Never => "never",
}
}
}
fn deserialize_resume_on_boot<'de, D>(deserializer: D) -> Result<Option<ResumeOnBoot>, D::Error>
where
D: Deserializer<'de>,
{
let raw = Option::<String>::deserialize(deserializer)?;
raw.map(|name| {
RESUME_ON_BOOT_POLICIES
.iter()
.find(|(n, _)| *n == name)
.map(|(_, policy)| *policy)
.ok_or_else(|| {
let known = RESUME_ON_BOOT_POLICIES
.iter()
.map(|(n, _)| format!("{n:?}"))
.collect::<Vec<_>>()
.join(", ");
D::Error::custom(format!(
"invalid [daemon].resume_on_boot {name:?}; expected one of {known}"
))
})
})
.transpose()
}
/// `[tui]` — `maestro tui` settings (NAP-275 epic, phase 2 NAP-277).
@@ -387,19 +313,16 @@ pub fn builtin_presets() -> Vec<SpawnPreset> {
id: "claude".into(),
label: "Claude".into(),
command: "claude".into(),
resume_command: Some("claude --resume {agent_session}".into()),
},
SpawnPreset {
id: "opencode".into(),
label: "opencode".into(),
command: "opencode".into(),
resume_command: None,
},
SpawnPreset {
id: "zsh".into(),
label: "Shell".into(),
command: "zsh".into(),
resume_command: None,
},
]
}
@@ -411,7 +334,6 @@ pub fn custom_preset() -> SpawnPreset {
id: CUSTOM_PRESET_ID.into(),
label: "Custom…".into(),
command: String::new(),
resume_command: None,
}
}
@@ -585,18 +507,6 @@ auto_install = false
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]
fn spawn_presets_override_replaces_builtins() {
// Present list replaces the built-ins entirely; custom still appended.
@@ -605,7 +515,6 @@ auto_install = false
id: "fish".into(),
label: "fish".into(),
command: "fish".into(),
resume_command: None,
}]),
..Default::default()
};
@@ -642,7 +551,6 @@ auto_install = false
id: " ".into(),
label: "x".into(),
command: "x".into(),
resume_command: None,
}]),
..Default::default()
};
@@ -653,7 +561,6 @@ auto_install = false
id: "x".into(),
label: "x".into(),
command: "".into(),
resume_command: None,
}]),
..Default::default()
};
@@ -671,13 +578,11 @@ auto_install = false
id: "a".into(),
label: "a".into(),
command: "a".into(),
resume_command: None,
},
SpawnPreset {
id: "a".into(),
label: "a2".into(),
command: "a2".into(),
resume_command: None,
},
]),
..Default::default()
@@ -692,7 +597,6 @@ auto_install = false
id: "custom".into(),
label: "Custom".into(),
command: "whatever".into(),
resume_command: None,
}]),
..Default::default()
};
@@ -728,27 +632,6 @@ presets = [
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]
fn spawn_project_scan_fields_default_to_none() {
// Absent [spawn].project_* → None; the daemon applies its own defaults.
@@ -849,27 +732,4 @@ bind_addr = "127.0.0.1:9090"
);
assert_eq!(cfg.resolve_daemon_url(None), "http://explicit:1");
}
#[test]
fn resume_on_boot_defaults_to_dormant_and_parses_every_policy() {
assert_eq!(
MaestroConfigFile::default().daemon.resume_on_boot(),
ResumeOnBoot::Dormant,
"an absent policy is the safe one"
);
for (name, expected) in RESUME_ON_BOOT_POLICIES {
let cfg = file_with(&format!("[daemon]\nresume_on_boot = \"{name}\"\n"));
assert_eq!(cfg.daemon.resume_on_boot(), *expected, "{name}");
assert_eq!(expected.as_str(), *name);
}
}
#[test]
fn resume_on_boot_rejects_an_unknown_policy() {
let err = toml::from_str::<MaestroConfigFile>("[daemon]\nresume_on_boot = \"maybe\"\n")
.expect_err("unknown policy must not silently fall back");
let msg = err.to_string();
assert!(msg.contains("resume_on_boot"), "{msg}");
assert!(msg.contains("\"auto\""), "{msg}");
}
}
-65
View File
@@ -1,65 +0,0 @@
//! Tells a daemon restart from a host reboot (#164).
//!
//! `/proc/sys/kernel/random/boot_id` is fixed for the life of the kernel and
//! changes on every boot. [`SessionManager::recover`] compares it against the
//! value it stored on the previous run and logs which of the two it was.
//!
//! It does not decide liveness (#174). A boot id changes without the tmux
//! server dying whenever the state directory is restored, a VM or container
//! snapshot is rolled back, or the `boot_id` file is truncated — so recovery
//! always probes `tmux list-sessions` and the boot id only says whether a
//! server that did not answer is coming back.
use std::path::Path;
/// Where the daemon reads the running kernel's boot id. Behind a trait so
/// tests can simulate a changed (or unreadable) boot id without rebooting the
/// machine.
pub trait BootIdSource: std::fmt::Debug + Send + Sync + 'static {
/// Current boot id, or `None` if it can't be determined (non-Linux,
/// restricted `/proc`, missing or empty file).
fn current(&self) -> Option<String>;
}
/// Production source: `/proc/sys/kernel/random/boot_id`.
#[derive(Debug, Default)]
pub struct ProcBootId;
impl BootIdSource for ProcBootId {
fn current(&self) -> Option<String> {
read_boot_id(Path::new("/proc/sys/kernel/random/boot_id"))
}
}
fn read_boot_id(path: &Path) -> Option<String> {
let contents = std::fs::read_to_string(path).ok()?;
let trimmed = contents.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_trimmed_contents() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("boot_id");
std::fs::write(&path, "abc-123\n").unwrap();
assert_eq!(read_boot_id(&path).as_deref(), Some("abc-123"));
}
#[test]
fn missing_file_returns_none() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(read_boot_id(&dir.path().join("missing")), None);
}
#[test]
fn empty_file_returns_none() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("boot_id");
std::fs::write(&path, "\n").unwrap();
assert_eq!(read_boot_id(&path), None);
}
}
+17 -21
View File
@@ -51,17 +51,23 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
// First-start hook auto-install (#106). Best-effort, before serving: a
// session spawned right away should already carry the hook.
if config.hooks_auto_install {
match crate::hooks::auto_install(&config.home_dir) {
crate::hooks::AutoInstallOutcome::Installed => {
tracing::info!("installed claude-code activity hook (#106)");
for (target, outcome) in crate::hooks::auto_install(&config.home_dir) {
let agent = target.label();
match outcome {
crate::hooks::AutoInstallOutcome::Installed => {
tracing::info!(agent, "installed activity hook (#106)");
}
crate::hooks::AutoInstallOutcome::Upgraded => {
tracing::info!(agent, "updated activity hook to this build (#106)");
}
crate::hooks::AutoInstallOutcome::Failed => {
tracing::warn!(
agent,
"hooks auto-install failed; run `maestro hooks install` manually"
);
}
_ => {}
}
crate::hooks::AutoInstallOutcome::Upgraded => {
tracing::info!("updated claude-code activity hook to this build (#106)");
}
crate::hooks::AutoInstallOutcome::Failed => {
tracing::warn!("hooks auto-install failed; run `maestro hooks install` manually");
}
_ => {}
}
}
// No worktree dir to pre-create: without an override the worktrees live
@@ -83,17 +89,7 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
.with_spawn_history(crate::spawn_history::SpawnHistory::new(
config.spawn_history_path(),
config.spawn_history_days,
))
// #171: mirror PTY scrollback so a resumed session opens on what it was
// showing, instead of a blank pane.
.with_scrollback(config.scrollback_dir())
.with_boot_id(
config.boot_id_path(),
std::sync::Arc::new(crate::boot_id::ProcBootId),
)
// #167: recovery needs the presets to resolve a resumed session's command,
// so `auto` runs the same resume a client's POST would.
.with_resume_policy(config.resume_on_boot, config.spawn_presets.clone());
));
// Spawned sessions learn where to report back (#103). Derived from the
// configured bind address; set before any spawn can happen.
manager.set_daemon_url(format!("http://{}", config.bind_addr));
-47
View File
@@ -97,10 +97,6 @@ pub struct DaemonConfig {
/// never binds it itself; it serves it so the web client and the TUI read
/// one knob.
pub leader: String,
/// What recovery does with sessions whose tmux pane is gone but whose
/// sidecar survives (#167): `[daemon].resume_on_boot`, default
/// [`maestro_config::ResumeOnBoot::Dormant`].
pub resume_on_boot: maestro_config::ResumeOnBoot,
}
impl DaemonConfig {
@@ -116,18 +112,6 @@ impl DaemonConfig {
pub fn spawn_history_path(&self) -> PathBuf {
self.state_dir.join("spawn-history.jsonl")
}
/// Where PTY scrollback is mirrored so a resumed session opens on what
/// it printed before (#171): `<state_dir>/scrollback`.
pub fn scrollback_dir(&self) -> PathBuf {
self.state_dir.join("scrollback")
}
/// Where recovery records the kernel boot id it saw last (#164):
/// `<state_dir>/boot_id`.
pub fn boot_id_path(&self) -> PathBuf {
self.state_dir.join("boot_id")
}
}
/// Env-var overrides applied by [`load_config`]. Tests pass explicit
@@ -159,8 +143,6 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
// validation (#22/#26).
let spawn_presets = file.spawn.resolve()?;
let resume_on_boot = daemon.resume_on_boot();
let hostname = daemon.hostname.unwrap_or_else(system_hostname);
let home_dir = dirs::home_dir()
.map(|p| p.to_string_lossy().into_owned())
@@ -221,7 +203,6 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
project_scan_depth,
hooks_auto_install: file.hooks.auto_install(),
leader: file.tui.leader().to_string(),
resume_on_boot,
})
}
@@ -302,34 +283,6 @@ auto_install = false
assert!(!cfg.hooks_auto_install);
}
#[test]
fn resume_on_boot_defaults_to_dormant_and_reads_the_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "").unwrap();
assert_eq!(
load_config(&path, EnvOverrides::default())
.unwrap()
.resume_on_boot,
maestro_config::ResumeOnBoot::Dormant
);
std::fs::write(
&path,
r#"
[daemon]
resume_on_boot = "auto"
"#,
)
.unwrap();
assert_eq!(
load_config(&path, EnvOverrides::default())
.unwrap()
.resume_on_boot,
maestro_config::ResumeOnBoot::Auto
);
}
#[test]
fn empty_file_defaults_everything() {
let dir = tempfile::tempdir().unwrap();
+107 -37
View File
@@ -1,37 +1,53 @@
//! First-start hook auto-install (#106).
//!
//! The daemon runs [`auto_install`] once at startup: unless the user's
//! `~/.claude/settings.json` already carries exactly what this build ships
//! (and `[hooks].auto_install` hasn't opted out — checked by the caller), the
//! same installer `maestro hooks install` uses writes it. That covers
//! upgrades as well as first starts: an install from a maestro that had fewer
//! events — #131 added `UserPromptSubmit` to a hook #106 had already put in
//! everyone's settings — is rewritten rather than left half-wired. Best-effort:
//! a failure is warned about and never blocks startup. The same global hook
//! serves every Claude Code session on the machine, so nothing here targets
//! repositories or worktrees.
//! The daemon runs [`auto_install`] once at startup: for every agent maestro
//! knows how to hook ([`maestro_hooks::Target`]), unless that agent's hook
//! file already carries exactly what this build ships (and `[hooks]
//! .auto_install` hasn't opted out — checked by the caller), the same
//! installer `maestro hooks install` uses writes it. That covers upgrades as
//! well as first starts: an install from a maestro that had fewer events —
//! #131 added `UserPromptSubmit` to a hook #106 had already put in everyone's
//! settings — is rewritten rather than left half-wired. Best-effort: a
//! failure is warned about and never blocks startup, and one target failing
//! does not stop the others. The same global hook serves every session on the
//! machine, so nothing here targets repositories or worktrees.
use std::path::Path;
/// Install the Claude Code activity hook for `home_dir` unless the current
/// one is already there or the home dir is unresolvable (`""`). Returns what
/// happened; an older install being brought up to date is
/// [`AutoInstallOutcome::Upgraded`], so the log can say so.
pub fn auto_install(home_dir: &str) -> AutoInstallOutcome {
use maestro_hooks::Target;
/// Install the activity hook for every [`Target`] under `home_dir`, skipping
/// any whose install is already current. Returns what happened per target —
/// an older install being brought up to date is
/// [`AutoInstallOutcome::Upgraded`], so the log can say so. An unresolvable
/// home dir (`""`) reports every target as [`AutoInstallOutcome::Skipped`].
pub fn auto_install(home_dir: &str) -> Vec<(Target, AutoInstallOutcome)> {
Target::ALL
.iter()
.map(|&target| (target, auto_install_target(target, home_dir)))
.collect()
}
fn auto_install_target(target: Target, home_dir: &str) -> AutoInstallOutcome {
if home_dir.is_empty() {
return AutoInstallOutcome::Skipped;
}
let settings = maestro_hooks::settings_path(Path::new(home_dir));
let script = maestro_hooks::script_path(Path::new(home_dir));
if maestro_hooks::is_current(&settings, &script) {
let home = Path::new(home_dir);
let settings = target.settings_path(home);
let script = maestro_hooks::script_path(home);
if maestro_hooks::is_current(target, &settings, &script) {
return AutoInstallOutcome::AlreadyPresent;
}
let stale = maestro_hooks::is_installed(&settings);
match maestro_hooks::install_for_home(Path::new(home_dir)) {
match maestro_hooks::install_target(target, home) {
Ok(_) if stale => AutoInstallOutcome::Upgraded,
Ok(_) => AutoInstallOutcome::Installed,
Err(e) => {
tracing::warn!(error = %e, "hooks auto-install failed (non-fatal)");
tracing::warn!(
target = target.label(),
error = %e,
"hooks auto-install failed (non-fatal)"
);
AutoInstallOutcome::Failed
}
}
@@ -52,15 +68,50 @@ pub enum AutoInstallOutcome {
mod tests {
use super::*;
/// The outcome recorded for one target.
fn outcome(outcomes: &[(Target, AutoInstallOutcome)], target: Target) -> AutoInstallOutcome {
outcomes
.iter()
.find(|(t, _)| *t == target)
.map(|(_, o)| *o)
.expect("every target is reported")
}
/// Everything happens under a temp `$HOME`; the developer's own
/// `~/.claude/settings.json` is never read or written.
/// `~/.claude/settings.json` and `~/.codex/hooks.json` are never read or
/// written.
#[test]
fn installs_when_settings_carry_no_maestro_hook() {
let dir = tempfile::tempdir().unwrap();
let outcome = auto_install(dir.path().to_str().unwrap());
assert_eq!(outcome, AutoInstallOutcome::Installed);
let settings = dir.path().join(".claude/settings.json");
assert!(maestro_hooks::is_installed(&settings));
let outcomes = auto_install(dir.path().to_str().unwrap());
for target in Target::ALL {
assert_eq!(
outcome(&outcomes, target),
AutoInstallOutcome::Installed,
"{}",
target.label()
);
assert!(maestro_hooks::is_installed(
&target.settings_path(dir.path())
));
}
}
/// Every agent maestro knows about is hooked, not just Claude Code: a
/// codex session reports activity and its first prompt the same way.
#[test]
fn codex_gets_its_own_hook_file() {
let dir = tempfile::tempdir().unwrap();
auto_install(dir.path().to_str().unwrap());
let hooks = dir.path().join(".codex/hooks.json");
let root: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&hooks).unwrap()).unwrap();
let events = root["hooks"].as_object().unwrap();
assert!(events.contains_key("PermissionRequest"));
assert!(
!events.contains_key("Notification"),
"codex has no Notification"
);
}
#[test]
@@ -69,14 +120,22 @@ mod tests {
let home = dir.path().to_str().unwrap();
auto_install(home);
// A current install is left exactly as it is.
let settings = dir.path().join(".claude/settings.json");
let before = std::fs::read_to_string(&settings).unwrap();
assert_eq!(auto_install(home), AutoInstallOutcome::AlreadyPresent);
assert_eq!(
before,
std::fs::read_to_string(&settings).unwrap(),
"already-installed settings must not be rewritten"
);
let before: Vec<String> = Target::ALL
.iter()
.map(|t| std::fs::read_to_string(t.settings_path(dir.path())).unwrap())
.collect();
let outcomes = auto_install(home);
for (i, target) in Target::ALL.iter().enumerate() {
assert_eq!(
outcome(&outcomes, *target),
AutoInstallOutcome::AlreadyPresent
);
assert_eq!(
before[i],
std::fs::read_to_string(target.settings_path(dir.path())).unwrap(),
"already-installed settings must not be rewritten"
);
}
}
/// An install from an older maestro — present, but missing an event this
@@ -87,7 +146,7 @@ mod tests {
let dir = tempfile::tempdir().unwrap();
let home = dir.path().to_str().unwrap();
auto_install(home);
let settings = dir.path().join(".claude/settings.json");
let settings = Target::ClaudeCode.settings_path(dir.path());
let script = maestro_hooks::script_path(dir.path());
let mut root: serde_json::Value =
@@ -96,12 +155,23 @@ mod tests {
std::fs::write(&settings, serde_json::to_string_pretty(&root).unwrap()).unwrap();
std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap();
assert_eq!(auto_install(home), AutoInstallOutcome::Upgraded);
assert!(maestro_hooks::is_current(&settings, &script));
let outcomes = auto_install(home);
assert_eq!(
outcome(&outcomes, Target::ClaudeCode),
AutoInstallOutcome::Upgraded
);
assert!(maestro_hooks::is_current(
Target::ClaudeCode,
&settings,
&script
));
}
#[test]
fn unresolvable_home_is_skipped() {
assert_eq!(auto_install(""), AutoInstallOutcome::Skipped);
let outcomes = auto_install("");
for target in Target::ALL {
assert_eq!(outcome(&outcomes, target), AutoInstallOutcome::Skipped);
}
}
}
-3
View File
@@ -12,7 +12,6 @@ pub mod attach;
/// Naming a session after its first prompt (#131).
#[cfg(feature = "haiku-autoname")]
pub mod autoname;
pub mod boot_id;
pub mod branches;
pub mod cli;
pub mod config;
@@ -25,8 +24,6 @@ pub mod proc;
pub mod projects;
pub mod pty;
pub mod release;
pub mod resume;
pub mod scrollback;
pub mod session;
pub mod sidecar;
pub mod spawn_history;
+1 -40
View File
@@ -52,7 +52,6 @@ pub fn router(state: LocalApiState) -> Router {
.route(routes::patterns::SESSION_ACTIVITY, post(report_activity))
.route(routes::patterns::SESSION_INPUT, post(send_input))
.route(routes::patterns::SESSION_FOCUS, post(set_focus))
.route(routes::patterns::SESSION_RESUME, post(resume_session))
.route(
routes::patterns::SESSION,
delete(kill_session).patch(rename_session),
@@ -171,9 +170,6 @@ fn session_error_status(code: &str) -> StatusCode {
// The session outlived its pane: nothing to type into, and nothing
// the daemon can do about it.
"not_running" => StatusCode::CONFLICT,
// Resume refusals (#166): the session exists and the request was
// well-formed, it just cannot be brought back in this state.
"already_running" | "cwd_missing" => StatusCode::CONFLICT,
_ => StatusCode::BAD_GATEWAY,
}
}
@@ -311,11 +307,7 @@ async fn report_activity(
Path(id): Path<Uuid>,
Json(body): Json<ReportActivityRequest>,
) -> Response {
match s
.manager
.report_activity(id, body.state, body.detail, body.session_id)
.await
{
match s.manager.report_activity(id, body.state, body.detail).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => {
let info = e.to_error_info();
@@ -372,27 +364,6 @@ async fn set_focus(
}
}
/// `POST /api/sessions/:id/resume` (#166): respawn a session that has no pane
/// under its own id and sidecar, so its name, `created_at`, env and worktree
/// ownership survive. Answers with the updated session, which also goes out as
/// `SessionUpdated` to every events subscriber.
///
/// Refused with `409` when the session is already running or its `cwd` is
/// gone; `404` when there is no such session.
async fn resume_session(State(s): State<LocalApiState>, Path(id): Path<Uuid>) -> Response {
match s.manager.resume(id, &s.presets).await {
Ok(session) => (StatusCode::OK, Json(session)).into_response(),
Err(e) => {
let info = e.to_error_info();
error_response(
session_error_status(&info.code),
info.code.as_str(),
info.message.as_str(),
)
}
}
}
fn error_response(status: StatusCode, code: &str, message: &str) -> Response {
(status, Json(ApiError::new(code, message))).into_response()
}
@@ -435,16 +406,6 @@ async fn attach_ws(
ws: axum::extract::WebSocketUpgrade,
) -> Result<Response, Response> {
let Some((replay, rx)) = s.manager.subscribe_pty_with_replay(id).await else {
// #166: a session the daemon knows but has no bridge for — a dormant
// one — is not missing, it is not running. Reporting it as unknown
// left the client unable to tell "gone" from "resumable".
if s.manager.has(id).await {
return Err(error_response(
StatusCode::CONFLICT,
"not_attachable",
"session has no live pane; resume it first",
));
}
return Err(error_response(
StatusCode::NOT_FOUND,
"unknown_session",
+1 -143
View File
@@ -21,11 +21,6 @@
//! one-shot prefix before joining the live broadcast, so attaching to an
//! idle session immediately shows the last screenful instead of a blank
//! terminal.
//! - #171: a bridge can be started with scrollback restored from disk — what
//! the session printed before the daemon (or the host) went away. Those
//! bytes are handed to subscribers ahead of the live ones until the ring
//! has a full budget of new output of its own, at which point the restored
//! history is older than anything the budget covers and is dropped.
use std::collections::VecDeque;
use std::ffi::CString;
@@ -63,10 +58,6 @@ const READ_BUF_BYTES: usize = 4096;
#[derive(Debug)]
pub struct PtyBridge {
fifo_path: PathBuf,
/// Scrollback from a previous life of this session (#171), replayed
/// ahead of the ring. Empty for a fresh spawn. Never appended to, so it
/// can never duplicate a byte the ring also holds.
restored: Vec<u8>,
/// `None` once [`Self::seal`] has run: live subscribers see the channel
/// close, but the replay buffer stays readable.
sender: Option<broadcast::Sender<Vec<u8>>>,
@@ -88,21 +79,6 @@ impl PtyBridge {
/// Variant that lets the caller pick a custom replay-buffer cap (bytes).
/// Pass `0` to disable replay (subscribers see only live bytes).
pub async fn start_with_replay(fifo_path: PathBuf, replay_bytes: usize) -> io::Result<Self> {
Self::start_restoring(fifo_path, replay_bytes, Vec::new()).await
}
/// As [`Self::start_with_replay`], seeded with `restored` scrollback read
/// back from disk (#171). The seed is truncated to the same
/// `replay_bytes` budget the ring uses — there is one budget here, not
/// two — and is dropped entirely when replay is disabled.
pub async fn start_restoring(
fifo_path: PathBuf,
replay_bytes: usize,
mut restored: Vec<u8>,
) -> io::Result<Self> {
if restored.len() > replay_bytes {
restored.drain(..restored.len() - replay_bytes);
}
if let Some(parent) = fifo_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
@@ -126,7 +102,6 @@ impl PtyBridge {
});
Ok(Self {
fifo_path,
restored,
sender: Some(sender),
replay,
last_activity,
@@ -180,8 +155,7 @@ impl PtyBridge {
F: FnOnce() -> Option<Vec<u8>>,
{
let guard = self.replay.lock_for_snapshot();
let live = capture().unwrap_or_else(|| guard.snapshot());
let snapshot = guard.with_restored_prefix(&self.restored, live);
let snapshot = capture().unwrap_or_else(|| guard.snapshot());
let rx = match &self.sender {
Some(tx) => tx.subscribe(),
// Sealed: hand back the scrollback plus a receiver that is already
@@ -197,25 +171,6 @@ impl PtyBridge {
(snapshot, rx)
}
/// Everything worth persisting for a later life of this session (#171):
/// the restored scrollback and the ring, trimmed together to the replay
/// budget so the file on disk is never bigger than the ring it mirrors.
pub fn scrollback(&self) -> Vec<u8> {
let guard = self.replay.lock_for_snapshot();
guard.with_restored_prefix(&self.restored, guard.snapshot())
}
/// Total bytes this bridge has appended to its ring. The scrollback flush
/// pass compares it against what it last wrote, so an idle session costs
/// no disk writes at all.
pub fn bytes_seen(&self) -> u64 {
self.replay
.inner
.lock()
.expect("ReplayBuffer poisoned")
.seen
}
/// Stop reading and close the live stream, keeping the replay buffer.
///
/// Attached clients see the channel close — without that a `maestro attach`
@@ -246,8 +201,6 @@ struct ReplayBuffer {
struct ReplayInner {
buf: VecDeque<u8>,
cap: usize,
/// Bytes ever appended, not bytes held — the ring evicts, this does not.
seen: u64,
}
impl ReplayBuffer {
@@ -256,7 +209,6 @@ impl ReplayBuffer {
inner: Arc::new(Mutex::new(ReplayInner {
buf: VecDeque::with_capacity(cap.min(READ_BUF_BYTES * 2)),
cap,
seen: 0,
})),
}
}
@@ -273,7 +225,6 @@ impl ReplayBuffer {
// If the incoming chunk alone exceeds the cap, take only its tail —
// anything in the buffer would be evicted before this finishes anyway.
let take_from = bytes.len().saturating_sub(s.cap);
s.seen += bytes.len() as u64;
let bytes = &bytes[take_from..];
s.buf.extend(bytes);
while s.buf.len() > s.cap {
@@ -296,24 +247,6 @@ struct ReplayGuard<'a> {
}
impl ReplayGuard<'_> {
/// `live` with the restored scrollback (#171) in front of it, while the
/// ring still holds less than a full budget of its own output. Once it
/// holds a full budget, everything restored is older than what the
/// budget covers, so it is dropped rather than kept forever.
fn with_restored_prefix(&self, restored: &[u8], live: Vec<u8>) -> Vec<u8> {
let cap = self.inner.cap;
let held = self.inner.buf.len();
if restored.is_empty() || cap == 0 || held >= cap {
return live;
}
let keep = cap - held;
let start = restored.len().saturating_sub(keep);
let mut out = Vec::with_capacity((restored.len() - start) + live.len());
out.extend_from_slice(&restored[start..]);
out.extend_from_slice(&live);
out
}
fn snapshot(&self) -> Vec<u8> {
let (a, b) = self.inner.buf.as_slices();
let mut out = Vec::with_capacity(a.len() + b.len());
@@ -359,7 +292,6 @@ async fn run_reader(
// never both, never neither.
let mut s = replay.inner.lock().expect("ReplayBuffer poisoned");
if s.cap > 0 {
s.seen += n as u64;
let take_from = n.saturating_sub(s.cap);
s.buf.extend(&buf[take_from..n]);
while s.buf.len() > s.cap {
@@ -413,80 +345,6 @@ fn shell_quote(s: &str) -> String {
mod tests {
use super::*;
async fn bridge(cap: usize, restored: &[u8]) -> (tempfile::TempDir, PtyBridge) {
let dir = tempfile::TempDir::new().expect("tempdir");
let bridge =
PtyBridge::start_restoring(dir.path().join("pty.fifo"), cap, restored.to_vec())
.await
.expect("start bridge");
(dir, bridge)
}
/// Feed the bridge through its own FIFO and wait for the reader to have
/// taken the bytes, so the ring state is deterministic.
async fn feed(bridge: &PtyBridge, bytes: &[u8]) {
use std::io::Write;
let before = bridge.bytes_seen();
let mut f = std::fs::OpenOptions::new()
.write(true)
.open(bridge.fifo_path())
.expect("open fifo for write");
f.write_all(bytes).expect("write fifo");
drop(f);
for _ in 0..400 {
if bridge.bytes_seen() >= before + bytes.len() as u64 {
return;
}
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
panic!("bridge never read the bytes written to its fifo");
}
#[tokio::test]
async fn restored_scrollback_is_replayed_ahead_of_live_bytes() {
let (_dir, bridge) = bridge(64, b"before the reboot\n").await;
feed(&bridge, b"after\n").await;
let (replay, _rx) = bridge.subscribe_with_replay();
assert_eq!(replay, b"before the reboot\nafter\n");
}
#[tokio::test]
async fn restored_scrollback_is_trimmed_to_the_replay_budget() {
let (_dir, bridge) = bridge(4, b"abcdefghij").await;
let (replay, _rx) = bridge.subscribe_with_replay();
assert_eq!(replay, b"ghij");
}
#[tokio::test]
async fn restored_scrollback_yields_once_the_ring_holds_a_full_budget() {
let (_dir, bridge) = bridge(4, b"old!").await;
feed(&bridge, b"ab").await;
let (replay, _rx) = bridge.subscribe_with_replay();
assert_eq!(replay, b"d!ab", "prefix plus ring stays inside the budget");
feed(&bridge, b"cd").await;
let (replay, _rx) = bridge.subscribe_with_replay();
assert_eq!(
replay, b"abcd",
"restored history is now older than the budget"
);
}
#[tokio::test]
async fn scrollback_carries_the_restored_prefix_and_the_ring() {
let (_dir, bridge) = bridge(8, b"old").await;
feed(&bridge, b"new").await;
assert_eq!(bridge.scrollback(), b"oldnew");
assert_eq!(bridge.bytes_seen(), 3);
}
#[tokio::test]
async fn no_replay_budget_means_no_restored_scrollback() {
let (_dir, bridge) = bridge(0, b"old").await;
let (replay, _rx) = bridge.subscribe_with_replay();
assert!(replay.is_empty());
assert!(bridge.scrollback().is_empty());
}
#[test]
fn replay_buffer_keeps_last_cap_bytes() {
let r = ReplayBuffer::new(4);
-99
View File
@@ -1,99 +0,0 @@
//! Choosing the command a resumed session comes back on (#166).
//!
//! A session records the command it was spawned with. Bringing it back means
//! running that preset's `resume_command` (#165) instead, so an agent picks up
//! its old conversation rather than starting a fresh one. Everything else
//! about the session — id, sidecar, name, `created_at`, env, worktree — is
//! untouched, so only the command is decided here.
use maestro_protocol::{Session, SpawnPreset};
/// The command to respawn `session` with.
///
/// The preset is matched on the command the session was spawned with, which is
/// what the sidecar holds. A session with no matching preset, a preset with no
/// `resume_command`, or a `resume_command` needing an agent session id the
/// session never reported all fall back to the original command: respawning
/// what the operator asked for is always honest, where a half-filled resume
/// template is not.
pub fn resume_command(session: &Session, presets: &[SpawnPreset]) -> String {
presets
.iter()
.find(|p| p.command == session.command)
.and_then(|p| p.resume_command_for(session.agent_session_id.as_deref()))
.unwrap_or_else(|| session.command.clone())
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn presets() -> Vec<SpawnPreset> {
vec![
SpawnPreset {
id: "claude".into(),
label: "Claude".into(),
command: "claude".into(),
resume_command: Some("claude --resume {agent_session}".into()),
},
SpawnPreset {
id: "zsh".into(),
label: "Shell".into(),
command: "zsh".into(),
resume_command: None,
},
]
}
fn session(command: &str, agent_session_id: Option<&str>) -> Session {
Session {
id: uuid::Uuid::new_v4(),
cwd: "/tmp".into(),
command: command.into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
status: maestro_protocol::SessionStatus::Dormant,
created_at: chrono::Utc::now(),
foreground: None,
activity: maestro_protocol::ActivityState::Idle,
activity_detail: None,
last_activity_at: chrono::Utc::now(),
activity_since: chrono::Utc::now(),
current_cwd: None,
blocked_work: None,
last_focused_at: None,
agent_session_id: agent_session_id.map(str::to_string),
}
}
#[test]
fn uses_the_matching_presets_resume_command() {
assert_eq!(
resume_command(&session("claude", Some("conv-7")), &presets()),
"claude --resume conv-7"
);
}
#[test]
fn falls_back_when_the_agent_session_id_is_unknown() {
assert_eq!(
resume_command(&session("claude", None), &presets()),
"claude"
);
}
#[test]
fn falls_back_for_a_preset_without_a_resume_command() {
assert_eq!(resume_command(&session("zsh", None), &presets()), "zsh");
}
#[test]
fn falls_back_when_no_preset_matches() {
assert_eq!(
resume_command(&session("bash --noprofile", Some("conv-7")), &presets()),
"bash --noprofile"
);
}
}
-148
View File
@@ -1,148 +0,0 @@
//! Persisted PTY scrollback (#171).
//!
//! The PTY replay ring lives in memory, so everything a session printed is
//! gone the moment the daemon process is. A resumed session then opens on a
//! blank pane: the agent's conversation is back, but the terminal history the
//! operator was reading is not.
//!
//! This store mirrors each bridge's ring to `<state_dir>/scrollback/<id>.bin`,
//! and hands it back when a session gets a new bridge — on resume, and on the
//! recovery pass that re-adopts a session tmux still holds. The file is
//! capped at the same `replay_bytes` budget the in-memory ring uses, so disk
//! cost is bounded by the same number the operator already configures.
//!
//! Nothing here is allowed to fail a resume. The payload is raw PTY bytes
//! with no framing to be corrupt, and a file that is missing, unreadable or
//! oversized degrades to an empty (or truncated) pane rather than an error.
use std::io;
use std::path::{Path, PathBuf};
use uuid::Uuid;
/// Filesystem-backed scrollback store, rooted at a directory.
#[derive(Debug, Clone)]
pub struct ScrollbackStore {
dir: PathBuf,
}
impl ScrollbackStore {
pub fn new(dir: impl Into<PathBuf>) -> Self {
Self { dir: dir.into() }
}
pub fn dir(&self) -> &Path {
&self.dir
}
pub fn path_for(&self, id: Uuid) -> PathBuf {
self.dir.join(format!("{id}.bin"))
}
/// Atomically write `bytes` to `<dir>/<id>.bin`, creating the directory if
/// needed. Written through a temp file and renamed, so a reboot in the
/// middle of a flush leaves the previous scrollback intact rather than a
/// half-file.
pub fn write(&self, id: Uuid, bytes: &[u8]) -> io::Result<()> {
std::fs::create_dir_all(&self.dir)?;
let tmp = self.dir.join(format!("{id}.bin.tmp"));
std::fs::write(&tmp, bytes)?;
std::fs::rename(&tmp, self.path_for(id))
}
/// The stored scrollback for `id`, capped to the last `budget` bytes.
///
/// Never fails: a missing file, an unreadable one, or a `budget` of zero
/// all read as "no scrollback", which is exactly the blank pane the
/// operator had before this existed.
pub fn read(&self, id: Uuid, budget: usize) -> Vec<u8> {
if budget == 0 {
return Vec::new();
}
let path = self.path_for(id);
let mut bytes = match std::fs::read(&path) {
Ok(b) => b,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Vec::new(),
Err(e) => {
tracing::warn!(error = ?e, %id, path = %path.display(), "scrollback: unreadable, resuming with a blank pane");
return Vec::new();
}
};
// A file written under a larger budget than this daemon now runs
// with keeps its tail, the same end of the stream the ring would.
if bytes.len() > budget {
bytes.drain(..bytes.len() - budget);
}
bytes
}
/// Delete the scrollback for `id`. No-op if it is not there.
pub fn delete(&self, id: Uuid) -> io::Result<()> {
match std::fs::remove_file(self.path_for(id)) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn store() -> (tempfile::TempDir, ScrollbackStore) {
let dir = tempfile::TempDir::new().expect("tempdir");
let store = ScrollbackStore::new(dir.path().join("scrollback"));
(dir, store)
}
#[test]
fn round_trips_bytes() {
let (_dir, store) = store();
let id = Uuid::new_v4();
store.write(id, b"hello world").expect("write");
assert_eq!(store.read(id, 64), b"hello world");
}
#[test]
fn a_missing_file_reads_as_empty() {
let (_dir, store) = store();
assert!(store.read(Uuid::new_v4(), 64).is_empty());
}
#[test]
fn a_file_over_budget_keeps_its_tail() {
let (_dir, store) = store();
let id = Uuid::new_v4();
store.write(id, b"abcdefghij").expect("write");
assert_eq!(store.read(id, 4), b"ghij");
}
#[test]
fn a_zero_budget_reads_as_empty() {
let (_dir, store) = store();
let id = Uuid::new_v4();
store.write(id, b"abcdefghij").expect("write");
assert!(store.read(id, 0).is_empty());
}
#[test]
fn delete_removes_the_file_and_tolerates_a_missing_one() {
let (_dir, store) = store();
let id = Uuid::new_v4();
store.write(id, b"gone soon").expect("write");
store.delete(id).expect("delete");
assert!(!store.path_for(id).exists());
store.delete(id).expect("second delete is a no-op");
}
#[test]
fn a_second_write_replaces_the_first() {
let (_dir, store) = store();
let id = Uuid::new_v4();
store.write(id, b"first").expect("write");
store.write(id, b"second").expect("rewrite");
assert_eq!(store.read(id, 64), b"second");
assert!(!store.dir().join(format!("{id}.bin.tmp")).exists());
}
}
+25 -620
View File
@@ -12,7 +12,6 @@ use std::sync::Arc;
use std::time::Duration;
use chrono::Utc;
use maestro_config::ResumeOnBoot;
use maestro_protocol::{
ActivityState, CatalogEvent, CreateSessionRequest, ErrorInfo, NameSource, Session,
SessionStatus, WorktreeSpec, NAME_KEY, NAME_SOURCE_KEY,
@@ -20,10 +19,8 @@ use maestro_protocol::{
use tokio::sync::{broadcast, Mutex};
use uuid::Uuid;
use crate::boot_id::BootIdSource;
use crate::panes::Panes;
use crate::pty::{pipe_pane_command, PtyBridge};
use crate::scrollback::ScrollbackStore;
use crate::sidecar::{SidecarError, SidecarStore};
use crate::spawn_history::{RecentDir, SpawnHistory, SpawnHistoryRecord};
use crate::tmux::TmuxError;
@@ -45,16 +42,6 @@ pub const DEFAULT_LIVENESS_INTERVAL: Duration = Duration::from_secs(2);
/// attempt, which is all #83 needs.
pub const DEFAULT_BLOCKED_WORK_INTERVAL: Duration = Duration::from_secs(15);
/// How often the liveness watcher mirrors PTY scrollback to disk (#171).
///
/// A host reboot gives no warning and runs no exit path, so this interval is
/// the whole guarantee: whatever a session printed in the last few seconds
/// before the power went is what a resumed pane is missing. Ten seconds keeps
/// that loss cosmetic — scrollback is visual continuity, not state — while
/// costing at most one file write per *active* session per tick, since a
/// session whose bridge read nothing is skipped entirely.
pub const DEFAULT_SCROLLBACK_INTERVAL: Duration = Duration::from_secs(10);
/// Gap between the text of a `send` and the Enter that submits it.
///
/// Agents that run a full-screen prompt (Claude Code, opencode) coalesce a
@@ -75,10 +62,7 @@ pub struct SessionRecord {
/// `base_repo` from the original `WorktreeSpec`. Needed because `git
/// worktree remove` is invoked from the base repo.
pub worktree_base_repo: Option<PathBuf>,
/// 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>,
pub bridge: PtyBridge,
/// Agent-reported activity (#105) still within its TTL. While in force it
/// outranks the activity pass's own inference; when it lapses, inference
/// resumes and [`Session::activity_detail`] is cleared with it. This
@@ -98,20 +82,11 @@ pub struct SessionRecord {
/// (`POST /.../focus`), `None` while unfocused. Daemon-local; never rides
/// the wire.
pub focused_since: Option<std::time::Instant>,
/// Set for as long as a [`SessionManager::resume`] is between its two
/// takes of the map lock (#175). The spawn happens with the lock released,
/// so the `Running` check alone lets a second request through; this is the
/// state the second one is refused on. Daemon-local; never rides the wire.
pub resuming: bool,
/// Sticky acknowledgement applied by [`crate::activity::apply_focus_suppression`]:
/// once the pane has been focused long enough to clear an `AwaitingInput`
/// glow, this holds through focus loss until the session does new work.
/// Daemon-local; never rides the wire.
pub awaiting_suppressed: bool,
/// [`PtyBridge::bytes_seen`] as of this session's last scrollback flush
/// (#171). The flush pass skips a session whose bridge has read nothing
/// since, so an idle session costs no disk writes.
pub flushed_bytes: u64,
}
#[derive(Debug, thiserror::Error)]
@@ -133,15 +108,6 @@ pub enum SessionError {
/// a stray keystroke on an attach socket, which is dropped.
#[error("session is no longer running: {0}")]
NotRunning(Uuid),
/// A resume (#166) was asked for a session that is already running. There
/// is nothing to bring back, and respawning would strand the pane that is
/// already there.
#[error("session is already running: {0}")]
AlreadyRunning(Uuid),
/// A resume (#166) was asked for a session whose working directory is
/// gone. Nothing can be resumed in a directory that no longer exists.
#[error("session working directory no longer exists: {0}")]
CwdMissing(PathBuf),
/// The kill was refused (#79): the session's worktree holds unsaved work.
/// Nothing happened — the session keeps running and the tree is untouched,
/// so it stays the operator's handle on their own unfinished work.
@@ -160,8 +126,6 @@ impl SessionError {
SessionError::InvalidEnv(_) => "invalid_env",
SessionError::NotRunning(_) => "not_running",
SessionError::KillGuarded(_) => "kill_guarded",
SessionError::AlreadyRunning(_) => "already_running",
SessionError::CwdMissing(_) => "cwd_missing",
};
let unsaved_work = match self {
SessionError::KillGuarded(b) => Some(*b),
@@ -212,34 +176,6 @@ struct Inner {
/// client that reconnects never collides with the registration its
/// previous socket has not finished releasing.
next_viewport: AtomicU64,
/// Boot-id tracking for `recover` (#164). `None` — the default — means
/// reboot detection is unwired, which behaves exactly like a boot id
/// that could never be read: always fall back to probing tmux.
boot_id: Option<BootIdConfig>,
/// What recovery does with a session whose pane is gone (#167), and the
/// presets an `auto` resume resolves its command from. Defaults to
/// [`ResumeOnBoot::Dormant`] with no presets: register and wait.
resume_policy: ResumeOnBoot,
resume_presets: Vec<maestro_protocol::SpawnPreset>,
/// Where PTY scrollback is mirrored so it survives the daemon, and the
/// host (#171). `None` — the default — means scrollback is not persisted
/// at all: a resumed session opens on a blank pane, exactly as before.
scrollback: Option<ScrollbackStore>,
}
/// Where recovery persists the boot id it last saw, and how it reads the
/// current one. See [`crate::boot_id`].
struct BootIdConfig {
path: PathBuf,
source: Arc<dyn BootIdSource>,
}
impl std::fmt::Debug for BootIdConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BootIdConfig")
.field("path", &self.path)
.finish_non_exhaustive()
}
}
impl SessionManager {
@@ -282,10 +218,6 @@ impl SessionManager {
events,
viewports: Mutex::new(Viewports::new()),
next_viewport: AtomicU64::new(0),
boot_id: None,
resume_policy: ResumeOnBoot::default(),
resume_presets: Vec::new(),
scrollback: None,
}),
}
}
@@ -315,56 +247,6 @@ impl SessionManager {
self
}
/// Wire boot-id tracking for `recover` (#164). Builder, called once right
/// after construction like the other `with_*` methods. Without it,
/// `recover` always falls back to probing tmux — the same as a boot id
/// that could never be read.
///
/// # Panics
///
/// If the handle has already been cloned.
/// Persist PTY scrollback under `dir` (#171), so a resumed session comes
/// back showing what it printed before the daemon — or the host — went
/// away. Builder, called once right after construction like the other
/// `with_*` methods. Without it nothing is written, read or deleted.
///
/// # Panics
///
/// If the handle has already been cloned; see [`Self::with_spawn_history`].
pub fn with_scrollback(mut self, dir: PathBuf) -> Self {
let inner = Arc::get_mut(&mut self.inner)
.expect("with_scrollback must be called before the SessionManager is cloned");
inner.scrollback = Some(ScrollbackStore::new(dir));
self
}
pub fn with_boot_id(mut self, path: PathBuf, source: Arc<dyn BootIdSource>) -> Self {
let inner = Arc::get_mut(&mut self.inner)
.expect("with_boot_id must be called before the SessionManager is cloned");
inner.boot_id = Some(BootIdConfig { path, source });
self
}
/// Wire the `[daemon].resume_on_boot` policy (#167) and the spawn presets
/// an `auto` resume resolves each session's command from. Builder, called
/// once right after construction like the other `with_*` methods. Without
/// it, recovery behaves as [`ResumeOnBoot::Dormant`].
///
/// # Panics
///
/// If the handle has already been cloned.
pub fn with_resume_policy(
mut self,
policy: ResumeOnBoot,
presets: Vec<maestro_protocol::SpawnPreset>,
) -> Self {
let inner = Arc::get_mut(&mut self.inner)
.expect("with_resume_policy must be called before the SessionManager is cloned");
inner.resume_policy = policy;
inner.resume_presets = presets;
self
}
/// Set the daemon's own HTTP base URL (#103), exported to every session
/// spawned afterwards as `MAESTRO_URL`. Unlike the `with_*` builders this
/// is a plain setter: the daemon runner learns its bound address after
@@ -409,10 +291,7 @@ impl SessionManager {
/// unknown to this daemon.
pub async fn subscribe_pty(&self, id: Uuid) -> Option<broadcast::Receiver<Vec<u8>>> {
let sessions = self.inner.sessions.lock().await;
sessions
.get(&id)
.and_then(|r| r.bridge.as_ref())
.map(|b| b.subscribe())
sessions.get(&id).map(|r| r.bridge.subscribe())
}
/// Subscribe to a session's PTY broadcast and return the replay buffer
@@ -431,26 +310,19 @@ impl SessionManager {
let (result, updated) = {
let mut sessions = self.inner.sessions.lock().await;
let rec = sessions.get_mut(&id)?;
let SessionRecord {
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();
rec.session.last_focused_at = Some(Utc::now());
let updated = rec.session.clone();
// 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)
// replayed from it renders as garbage until something forces a
// repaint. A dead session has no pane left to capture, so it
// keeps the byte tail.
let alive = session.status == SessionStatus::Running;
let alive = rec.session.status == SessionStatus::Running;
// The capture shells out to tmux while the replay lock is held,
// which parks the reader for the length of one tmux round-trip.
// That only happens on attach, and the alternative is a seam in
// the stream.
let result = bridge.subscribe_with_snapshot(|| {
let result = rec.bridge.subscribe_with_snapshot(|| {
if !alive {
return None;
}
@@ -606,7 +478,6 @@ impl SessionManager {
// fresh tree has nothing in the way anyway.
blocked_work: None,
last_focused_at: None,
agent_session_id: None,
};
if let Err(e) = self.inner.sidecar.write(&session) {
@@ -628,15 +499,13 @@ impl SessionManager {
fifo_path,
worktree_path,
worktree_base_repo,
bridge: Some(bridge),
bridge,
reported_activity: None,
settled: false,
#[cfg(feature = "haiku-autoname")]
naming: false,
focused_since: None,
awaiting_suppressed: false,
resuming: false,
flushed_bytes: 0,
},
);
}
@@ -667,184 +536,6 @@ impl SessionManager {
Ok(session)
}
/// Bring a session that has no pane back to life (#166).
///
/// The session comes back under the **same** uuid and the same sidecar, so
/// `metadata.name`, `created_at`, `env` and worktree ownership all survive
/// — only the tmux session and the PTY bridge are new. That is the whole
/// point: spawning a fresh session at the same path instead produces a
/// stranger with no worktree to clean up on kill.
///
/// The command is [`crate::resume::resume_command`]: the matching preset's
/// `resume_command` (#165) with the session's own agent conversation id
/// substituted in, falling back to the command the session was spawned
/// with. `Session::command` keeps the original either way, so a second
/// resume still resolves the same preset.
///
/// Refused for a session that is already running, and for one whose `cwd`
/// has disappeared — neither is something a respawn could fix.
pub async fn resume(
&self,
id: Uuid,
presets: &[maestro_protocol::SpawnPreset],
) -> Result<Session, SessionError> {
// The spawn below runs with the map lock released, so the `Running`
// check alone is not enough to keep two resumes apart (#175): both
// would pass it, both would open a `PtyBridge` on the same FIFO, and
// the loser would report a tmux failure instead of the conflict it
// is. The record is marked in-flight under this first lock, and every
// path out of here clears it again.
let mut session = {
let mut sessions = self.inner.sessions.lock().await;
let rec = sessions.get_mut(&id).ok_or(SessionError::NotFound(id))?;
if rec.session.status == SessionStatus::Running || rec.resuming {
return Err(SessionError::AlreadyRunning(id));
}
rec.resuming = true;
rec.session.clone()
};
if !session.cwd.exists() {
self.clear_resuming(id).await;
return Err(SessionError::CwdMissing(session.cwd.clone()));
}
let command = crate::resume::resume_command(&session, presets);
// Same fifo path as the original spawn: it is derived from the id, and
// `mkfifo` tolerates the stale node the previous run left behind.
let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo"));
// #171: the pane is new, but the scrollback the operator was reading
// before the reboot is on disk. It goes in ahead of the live bytes.
let bridge = PtyBridge::start_restoring(
fifo_path.clone(),
self.inner.replay_bytes,
self.restored_scrollback(id),
)
.await?;
let mut session_env = sanitize_env(&session.env)?;
session_env.insert("MAESTRO_SESSION_ID".into(), id.to_string());
if let Some(url) = self
.inner
.daemon_url
.lock()
.expect("daemon_url mutex")
.clone()
{
session_env.insert("MAESTRO_URL".into(), url);
}
let spawned = {
let tmux = self.inner.tmux.clone();
let cwd = session.cwd.clone();
let command = command.clone();
let sink = pipe_pane_command(&fifo_path);
tmux_blocking(move || tmux.new_session_piped(id, &cwd, &command, &session_env, &sink))
.await
};
if let Err(e) = spawned {
// `bridge` drops here, closing the FIFO reader. Nothing else was
// touched, so the session stays exactly as dormant as it was.
self.clear_resuming(id).await;
return Err(SessionError::Tmux(e));
}
session.status = SessionStatus::Running;
// A resumed session is producing its initial output again; the
// watcher refines this on its next tick, exactly as after a spawn.
session.foreground = None;
session.activity = ActivityState::Active;
session.activity_detail = None;
session.last_activity_at = Utc::now();
session.activity_since = Utc::now();
session.current_cwd = None;
session.last_focused_at = None;
if let Err(e) = self.inner.sidecar.write(&session) {
// tmux already spawned; kill it rather than leave a pane the
// sidecar does not describe.
let tmux = self.inner.tmux.clone();
let _ = tmux_blocking(move || tmux.kill_session(id)).await;
self.clear_resuming(id).await;
return Err(SessionError::Sidecar(e));
}
{
let mut sessions = self.inner.sessions.lock().await;
let Some(rec) = sessions.get_mut(&id) else {
// Killed while we were spawning. Undo the pane instead of
// leaving one behind with no record of it — and delete the
// sidecar the write above just put back (#175). The kill
// deleted it before that write landed, so leaving it would
// have the next recovery register a dormant ghost for a
// session that is gone and whose worktree is already removed.
drop(sessions);
let tmux = self.inner.tmux.clone();
let _ = tmux_blocking(move || tmux.kill_session(id)).await;
if let Err(e) = self.inner.sidecar.delete(id) {
tracing::warn!(error = ?e, %id, "resume: failed to delete the sidecar of a session killed mid-resume");
}
return Err(SessionError::NotFound(id));
};
rec.session = session.clone();
rec.fifo_path = fifo_path;
rec.bridge = Some(bridge);
rec.reported_activity = None;
rec.settled = false;
rec.focused_since = None;
rec.awaiting_suppressed = false;
rec.resuming = false;
}
let _ = self.inner.events.send(CatalogEvent::SessionUpdated {
session: session.clone(),
});
Ok(session)
}
/// Delete the scrollback of a sidecar recovery threw away (#171): the
/// session will never be listed again, so nothing would ever read it.
fn prune_scrollback(&self, id: Uuid) {
if let Some(store) = &self.inner.scrollback {
if let Err(e) = store.delete(id) {
tracing::warn!(error = ?e, %id, "recover: failed to prune scrollback");
}
}
}
/// Scrollback persisted for `id` by an earlier life of this session
/// (#171), capped to the replay budget. Empty when scrollback is not
/// wired, and empty — never an error — when the file is missing or
/// unreadable: a blank pane is the honest fallback, and it is what the
/// operator had before any of this existed.
fn restored_scrollback(&self, id: Uuid) -> Vec<u8> {
self.inner
.scrollback
.as_ref()
.map(|store| store.read(id, self.inner.replay_bytes))
.unwrap_or_default()
}
/// Mirror every live bridge's scrollback to disk (#171).
///
/// Called on a timer by the liveness watcher rather than only on a clean
/// exit, because the case this exists for — a host reboot — runs no exit
/// path at all: anything flushed only on shutdown is missing exactly when
/// it is needed. Sessions whose bridge has read nothing since the last
/// flush are skipped, so an idle host writes nothing.
///
/// Best-effort throughout: a failed write is warned and the session keeps
/// running.
pub async fn flush_scrollback(&self) {
run_scrollback_pass(&self.inner).await;
}
/// Drop the in-flight marker [`Self::resume`] set under its first lock
/// (#175), so a refused or failed resume can be retried. A no-op when the
/// record is already gone: a kill that landed mid-resume took the marker
/// with it.
async fn clear_resuming(&self, id: Uuid) {
if let Some(rec) = self.inner.sessions.lock().await.get_mut(&id) {
rec.resuming = false;
}
}
/// Best-effort removal of a worktree created earlier in `create` when a
/// later step fails. No-op when the request had no worktree.
fn rollback_worktree(&self, base: &Option<PathBuf>, path: &Option<PathBuf>) {
@@ -935,10 +626,6 @@ impl SessionManager {
// Start "due" so the first live tick probes: a recovered daemon
// should not wait a full interval to show what blocks.
let mut since_probe = blocked_work_interval;
// Scrollback starts *not* due: a daemon that just recovered has
// nothing new to mirror, and the sessions it adopted were primed
// from the files this would overwrite (#171).
let mut since_flush = Duration::ZERO;
loop {
ticker.tick().await;
let Some(inner) = weak.upgrade() else {
@@ -953,11 +640,6 @@ impl SessionManager {
since_probe = Duration::ZERO;
run_blocked_work_pass(&inner).await;
}
since_flush += interval;
if since_flush >= DEFAULT_SCROLLBACK_INTERVAL {
since_flush = Duration::ZERO;
run_scrollback_pass(&inner).await;
}
}
});
}
@@ -969,20 +651,8 @@ impl SessionManager {
/// and start a `PtyBridge`. Sessions whose sidecar references a `cwd`
/// that no longer exists are still adopted — the spec does not require
/// us to repair them.
///
/// #164/#174: the kernel's boot id says whether the host rebooted since
/// the last run, but it never decides which sessions are alive —
/// `tmux list-sessions` does, always, which is what the rest of the
/// daemon assumes. A boot id can change without the tmux server dying
/// (a restored state directory, a VM or container snapshot restore, a
/// truncated `boot_id` file); skipping the probe then registered every
/// live session `Dormant`, and resume could never bring one back because
/// the tmux name was still taken. The boot id is now only a signal that a
/// tmux server which did not answer is not coming back, and it names
/// itself in recovery's log.
pub async fn recover(&self) -> Result<usize, SessionError> {
tokio::fs::create_dir_all(&self.inner.fifo_dir).await?;
let rebooted = self.check_boot_id().await;
let live = {
let tmux = self.inner.tmux.clone();
tmux_blocking(move || tmux.list_sessions()).await?
@@ -1013,19 +683,11 @@ impl SessionManager {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
agent_session_id: None,
},
};
let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo"));
// The ring the previous daemon held died with it, so an adopted
// session would otherwise replay nothing until tmux's own screen
// capture — which is one screenful, not scrollback (#171).
let bridge = PtyBridge::start_restoring(
fifo_path.clone(),
self.inner.replay_bytes,
self.restored_scrollback(id),
)
.await?;
let bridge =
PtyBridge::start_with_replay(fifo_path.clone(), self.inner.replay_bytes).await?;
// Re-pipe so output starts flowing again. `Tmux::pipe_pane` runs
// without tmux's `-o` flag, so any stale pipe the previous daemon
// left running to a now-unread FIFO is replaced (NAP-228).
@@ -1045,15 +707,13 @@ impl SessionManager {
// directory blocks that branch forever (#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: Some(bridge),
bridge,
reported_activity: None,
settled: false,
#[cfg(feature = "haiku-autoname")]
naming: false,
focused_since: None,
awaiting_suppressed: false,
resuming: false,
flushed_bytes: 0,
},
);
let _ = self
@@ -1062,195 +722,16 @@ impl SessionManager {
.send(CatalogEvent::SessionAdded { session });
adopted += 1;
}
self.register_dormant(rebooted).await;
Ok(adopted)
}
/// Compare the running kernel's boot id against the one recovery stored
/// last time (#164), then persist the current one for next time. Returns
/// `true` only when both are known and differ — a host reboot.
///
/// A missing stored value (first run) or an unreadable current one
/// (non-Linux, restricted `/proc`, unwired in this build) both read as
/// "no reboot". Either way the tmux probe runs (#174): the answer only
/// tells recovery whether a tmux server that did not answer is coming
/// back, never which sessions are alive.
async fn check_boot_id(&self) -> bool {
let Some(cfg) = &self.inner.boot_id else {
return false;
};
let Some(current) = cfg.source.current() else {
return false;
};
let stored = tokio::fs::read_to_string(&cfg.path)
.await
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let rebooted = stored.is_some_and(|s| s != current);
if let Some(parent) = cfg.path.parent() {
let _ = tokio::fs::create_dir_all(parent).await;
}
if let Err(e) = tokio::fs::write(&cfg.path, &current).await {
tracing::warn!(error = ?e, path = %cfg.path.display(), "recover: failed to persist boot id");
}
rebooted
}
/// Second recovery pass (#162): register every sidecar tmux had nothing to
/// adopt as a [`SessionStatus::Dormant`] session.
///
/// 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.
///
/// #163: a sidecar whose `cwd` no longer exists is pruned instead —
/// nothing can be resumed in a directory that is gone, so the honest move
/// is to delete the sidecar rather than register a record no operator can
/// act on. A sidecar whose `cwd` still exists is kept regardless of age.
///
/// `rebooted` (#164) only changes what gets logged: a detected reboot
/// names the count even when it is zero, so the operator sees that
/// recovery told a reboot from a restart instead of a log that stays
/// silent either way. It never decides membership — everything here got
/// here by tmux not holding it (#174).
async fn register_dormant(&self, rebooted: bool) {
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 pruned = 0usize;
let mut discarded = 0usize;
{
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;
}
if !session.cwd.exists() {
if let Err(e) = self.inner.sidecar.delete(session.id) {
tracing::warn!(error = ?e, id = %session.id, "recover: failed to prune sidecar with missing cwd");
}
self.prune_scrollback(session.id);
pruned += 1;
continue;
}
if self.inner.resume_policy == ResumeOnBoot::Never {
// #167: this host wants a reboot to be a clean slate, so
// the sidecar goes the way #163's does — nothing is
// registered and the next recovery finds nothing.
if let Err(e) = self.inner.sidecar.delete(session.id) {
tracing::warn!(error = ?e, id = %session.id, "recover: failed to prune sidecar under resume_on_boot=never");
}
self.prune_scrollback(session.id);
discarded += 1;
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,
resuming: false,
flushed_bytes: 0,
},
);
registered.push(session);
}
}
if rebooted {
tracing::info!(
dormant = registered.len(),
"host rebooted, sessions dormant"
);
} else if !registered.is_empty() {
tracing::info!(
dormant = registered.len(),
"registered sessions with no live tmux as dormant"
);
}
if pruned > 0 {
tracing::info!(pruned, "pruned sidecars whose cwd no longer exists");
}
let resumable: Vec<Uuid> = registered.iter().map(|s| s.id).collect();
for session in registered {
let _ = self
.inner
.events
.send(CatalogEvent::SessionAdded { session });
}
// #167: say which policy ran and how many sessions it acted on, so a
// host that resumes agents unattended says so in its own log. The
// match is exhaustive on purpose: a fourth policy must decide what it
// does here rather than inherit whichever arm happened to be last.
match self.inner.resume_policy {
ResumeOnBoot::Dormant => tracing::info!(
policy = ResumeOnBoot::Dormant.as_str(),
sessions = resumable.len(),
"resume_on_boot: sessions left dormant for the operator"
),
ResumeOnBoot::Never => tracing::info!(
policy = ResumeOnBoot::Never.as_str(),
sessions = discarded,
"resume_on_boot: pruned the sidecars of sessions with no live pane"
),
ResumeOnBoot::Auto => {
tracing::info!(
policy = ResumeOnBoot::Auto.as_str(),
sessions = resumable.len(),
"resume_on_boot: resuming sessions with no live pane"
);
let presets = self.inner.resume_presets.clone();
for id in resumable {
// The same call the client's POST /api/sessions/:id/resume
// makes — one resume path, not two.
if let Err(e) = self.resume(id, &presets).await {
tracing::warn!(error = %e, id = %id, "resume_on_boot=auto: resume failed; session stays dormant");
}
}
}
}
}
/// 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.
/// Forward keystrokes to a session's pane. A dead 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> {
match self.status(id).await {
None => return Err(SessionError::NotFound(id)),
Some(SessionStatus::Dead | SessionStatus::Dormant) => return Ok(()),
Some(SessionStatus::Dead) => return Ok(()),
Some(SessionStatus::Running) => {}
}
let tmux = self.inner.tmux.clone();
@@ -1263,15 +744,13 @@ impl SessionManager {
/// Type `text` into a session's pane, with an Enter after it when
/// `submit` is set.
///
/// Unlike [`Self::send_keys`], a dead or dormant session is an error: the
/// caller asked for text to land somewhere, and a silent success would let
/// a script believe an agent had been told something it never heard.
/// Unlike [`Self::send_keys`], a dead session is an error: the caller
/// asked for text to land somewhere, and a silent success would let 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> {
match self.status(id).await {
None => return Err(SessionError::NotFound(id)),
Some(SessionStatus::Dead | SessionStatus::Dormant) => {
return Err(SessionError::NotRunning(id))
}
Some(SessionStatus::Dead) => return Err(SessionError::NotRunning(id)),
Some(SessionStatus::Running) => {}
}
let tmux = self.inner.tmux.clone();
@@ -1289,12 +768,12 @@ impl SessionManager {
Ok(())
}
/// Resize a session's window. No-op for a dead or dormant session, as with
/// Resize a session's window. No-op for a dead session, as with
/// [`Self::send_keys`].
pub async fn resize(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), SessionError> {
match self.status(id).await {
None => return Err(SessionError::NotFound(id)),
Some(SessionStatus::Dead | SessionStatus::Dormant) => return Ok(()),
Some(SessionStatus::Dead) => return Ok(()),
Some(SessionStatus::Running) => {}
}
let tmux = self.inner.tmux.clone();
@@ -1402,20 +881,11 @@ impl SessionManager {
/// A report that changes the effective state restarts `activity_since`
/// (#104's clock); re-reporting an unchanged state only extends the TTL
/// and stays silent on the event stream at steady state.
///
/// `agent_session_id` (#161) is the agent's own conversation id, parsed
/// by the caller from the hook payload's `session_id` field. The
/// sidecar is rewritten only when the reported id differs from the
/// stored one — a repeated report of the same id is pure churn from the
/// hook's `PreToolUse` cadence and writes nothing, but a resume (#176)
/// issues a new conversation id and must replace the stale one, or
/// every later resume reattaches the pre-resume conversation.
pub async fn report_activity(
&self,
id: Uuid,
state: ActivityState,
detail: Option<String>,
agent_session_id: Option<String>,
) -> Result<(), SessionError> {
let updated = {
let mut sessions = self.inner.sessions.lock().await;
@@ -1431,16 +901,7 @@ impl SessionManager {
state,
self.inner.report_ttl,
));
let id_changed =
agent_session_id.is_some() && rec.session.agent_session_id != agent_session_id;
if id_changed {
rec.session.agent_session_id = agent_session_id;
self.inner
.sidecar
.write(&rec.session)
.map_err(SessionError::Sidecar)?;
}
(changed || id_changed).then(|| rec.session.clone())
changed.then(|| rec.session.clone())
};
if let Some(session) = updated {
let _ = self
@@ -1713,11 +1174,7 @@ async fn run_activity_pass(inner: &Arc<Inner>) {
rec.session.activity_detail = None;
}
}
// 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;
};
let idle = rec.bridge.idle_for();
// #105: while a report is in force it supplies the state and
// nothing here may overwrite it; only an unreported session is
// decided by inference.
@@ -1823,36 +1280,6 @@ async fn run_blocked_work_pass(inner: &Arc<Inner>) {
}
}
/// Mirror every live bridge's scrollback to disk. See
/// [`SessionManager::flush_scrollback`].
async fn run_scrollback_pass(inner: &Arc<Inner>) {
let Some(store) = inner.scrollback.clone() else {
return;
};
// Snapshot under the lock, write with it released: the writes are
// filesystem work and every attach needs this map.
let pending: Vec<(Uuid, Vec<u8>)> = {
let mut sessions = inner.sessions.lock().await;
sessions
.iter_mut()
.filter_map(|(id, rec)| {
let bridge = rec.bridge.as_ref()?;
let seen = bridge.bytes_seen();
if seen == rec.flushed_bytes {
return None;
}
rec.flushed_bytes = seen;
Some((*id, bridge.scrollback()))
})
.collect()
};
for (id, bytes) in pending {
if let Err(e) = store.write(id, &bytes) {
tracing::warn!(error = ?e, %id, "scrollback: flush failed");
}
}
}
/// Mark a session whose tmux session has disappeared as [`SessionStatus::Dead`]
/// and settle its worktree per the natural-exit rule (#82).
///
@@ -1865,10 +1292,8 @@ async fn run_scrollback_pass(inner: &Arc<Inner>) {
/// alone and stays attached to the dead record for a later forced dismissal
/// (#81), which leaves it on disk too.
///
/// A dead session does not come back as dead across a daemon restart: its
/// sidecar survives, so [`SessionManager::recover`]'s second pass registers it
/// as [`SessionStatus::Dormant`] (#162) — accurate either way, since neither
/// has a pane behind it.
/// Dead sessions do not survive a daemon restart: [`SessionManager::recover`]
/// adopts only what tmux still holds.
async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
let flipped = {
let mut sessions = inner.sessions.lock().await;
@@ -1877,18 +1302,7 @@ async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
record.session.status = SessionStatus::Dead;
// Release anyone attached; the scrollback survives for a later
// attach.
if let Some(bridge) = record.bridge.as_mut() {
bridge.seal();
// The clean-exit half of #171: the last thing the session
// printed is what a later resume should open on, and it
// will produce nothing more for the flush pass to notice.
if let Some(store) = &inner.scrollback {
if let Err(e) = store.write(id, &bridge.scrollback()) {
tracing::warn!(error = ?e, %id, "scrollback: final flush failed");
}
record.flushed_bytes = bridge.bytes_seen();
}
}
record.bridge.seal();
let session = record.session.clone();
let worktree = record
.worktree_base_repo
@@ -1909,7 +1323,6 @@ async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
// The tree is settled (removed); a stale "in the way" would
// describe something the session no longer holds.
session.blocked_work = None;
session.worktree = None;
let mut sessions = inner.sessions.lock().await;
if let Some(record) = sessions.get_mut(&id) {
record.session.worktree = None;
@@ -2009,14 +1422,6 @@ async fn cleanup_record(
if let Err(e) = inner.sidecar.delete(id) {
tracing::warn!(error = ?e, %id, "cleanup: failed to delete sidecar");
}
// #171: the session is gone, so its scrollback goes with it — nothing
// will ever read the file again, and leaving it would keep a killed
// session's output on disk indefinitely.
if let Some(store) = &inner.scrollback {
if let Err(e) = store.delete(id) {
tracing::warn!(error = ?e, %id, "cleanup: failed to delete scrollback");
}
}
if let (Some(base), Some(path)) = (&record.worktree_base_repo, &record.worktree_path) {
if keep_worktree {
tracing::info!(
+40 -12
View File
@@ -111,16 +111,33 @@ impl WorktreeManager {
// Clear registrations whose directory is gone first, so a
// previously-leaked entry doesn't push this branch to a suffix.
prune(base_repo)?;
// Anything occupying the target — a tree we left behind, one the
// operator made, an unrelated directory — is sidestepped with a
// numeric suffix on the directory; the branch stays whatever was
// requested (#77). The daemon never adopts what it finds.
let mut path = self.target_path(base_repo, branch);
let mut n = 1;
while path.symlink_metadata().is_ok() {
n += 1;
path = self.target_path_suffixed(base_repo, branch, n);
}
let (branch, path) = if create_branch {
// Worktrees are commonly abandoned without deleting their branch,
// so a random handle can collide with one of its own past runs.
// Unlike the `add_existing` arm below, the suffix goes on the
// branch name itself — the path is derived from it anyway.
let mut candidate = branch.to_string();
let mut path = self.target_path(base_repo, &candidate);
let mut n = 1;
while path.symlink_metadata().is_ok() || branch_exists(base_repo, &candidate) {
n += 1;
candidate = format!("{branch}-{n}");
path = self.target_path(base_repo, &candidate);
}
(candidate, path)
} else {
// Existing branch: the name must stay exact, so only the
// directory — anything occupying it, ours or the operator's — is
// sidestepped with a numeric suffix (#77). The daemon never
// adopts what it finds.
let mut path = self.target_path(base_repo, branch);
let mut n = 1;
while path.symlink_metadata().is_ok() {
n += 1;
path = self.target_path_suffixed(base_repo, branch, n);
}
(branch.to_string(), path)
};
// The parent (`<repo>/worktrees` or the override root) may not exist
// yet; the target itself must not.
if let Some(parent) = path.parent() {
@@ -129,10 +146,10 @@ impl WorktreeManager {
let mut cmd = Command::new("git");
cmd.arg("-C").arg(base_repo).args(["worktree", "add"]);
if create_branch {
cmd.arg("-b").arg(branch).arg(&path);
cmd.arg("-b").arg(&branch).arg(&path);
} else {
// Existing branch: `worktree add <path> <branch>` with no `-b`.
cmd.arg(&path).arg(branch);
cmd.arg(&path).arg(&branch);
}
let out = cmd.output()?;
check(out)?;
@@ -266,6 +283,17 @@ fn branch_relpath(branch: &str) -> Option<PathBuf> {
Some(candidate.to_path_buf())
}
/// Whether `refs/heads/<branch>` already exists in `base_repo`.
fn branch_exists(base_repo: &Path, branch: &str) -> bool {
Command::new("git")
.arg("-C")
.arg(base_repo)
.args(["show-ref", "--verify", "--quiet"])
.arg(format!("refs/heads/{branch}"))
.status()
.is_ok_and(|s| s.success())
}
/// `git -C <base_repo> worktree prune` — drops registrations whose directory
/// no longer exists.
fn prune(base_repo: &Path) -> Result<(), WorktreeError> {
+5 -112
View File
@@ -18,7 +18,6 @@ use uuid::Uuid;
struct Fixture {
manager: SessionManager,
sidecar: SidecarStore,
_tmux: TmuxFixture,
_state_dir: TempDir,
}
@@ -26,17 +25,15 @@ struct Fixture {
async fn fixture(ttl: Duration) -> Fixture {
let tmux = TmuxFixture::new();
let state_dir = TempDir::new().unwrap();
let sidecar = SidecarStore::new(state_dir.path().join("sessions"));
let manager = SessionManager::new(
Arc::new(tmux.tmux.clone()),
sidecar.clone(),
SidecarStore::new(state_dir.path().join("sessions")),
WorktreeManager::new(None),
state_dir.path().join("fifos"),
)
.with_activity_report_ttl(ttl);
Fixture {
manager,
sidecar,
_tmux: tmux,
_state_dir: state_dir,
}
@@ -70,7 +67,7 @@ async fn report_on_unknown_session_is_not_found() {
let fx = fixture(Duration::from_secs(300)).await;
let err = fx
.manager
.report_activity(Uuid::new_v4(), ActivityState::Idle, None, None)
.report_activity(Uuid::new_v4(), ActivityState::Idle, None)
.await
.unwrap_err();
assert!(matches!(err, maestro_daemon::SessionError::NotFound(_)));
@@ -86,7 +83,6 @@ async fn report_sets_error_state_and_detail() {
id,
ActivityState::Error,
Some("cargo build failed: link error".into()),
None,
)
.await
.unwrap();
@@ -105,7 +101,7 @@ async fn replacing_a_report_replaces_both_state_and_detail() {
let id = spawn_sleeping(&fx.manager).await;
fx.manager
.report_activity(id, ActivityState::Error, Some("first failure".into()), None)
.report_activity(id, ActivityState::Error, Some("first failure".into()))
.await
.unwrap();
fx.manager
@@ -113,7 +109,6 @@ async fn replacing_a_report_replaces_both_state_and_detail() {
id,
ActivityState::AwaitingInput,
Some("needs approval".into()),
None,
)
.await
.unwrap();
@@ -130,7 +125,7 @@ async fn detail_is_capped_server_side() {
let long = "x".repeat(10_000);
fx.manager
.report_activity(id, ActivityState::Error, Some(long), None)
.report_activity(id, ActivityState::Error, Some(long))
.await
.unwrap();
@@ -149,12 +144,7 @@ async fn reported_state_survives_watcher_ticks_until_the_ttl_then_inference_resu
fx.manager.start_liveness_watcher(Duration::from_millis(50));
fx.manager
.report_activity(
id,
ActivityState::Error,
Some("agent died mid-run".into()),
None,
)
.report_activity(id, ActivityState::Error, Some("agent died mid-run".into()))
.await
.unwrap();
@@ -172,100 +162,3 @@ async fn reported_state_survives_watcher_ticks_until_the_ttl_then_inference_resu
})
.await;
}
#[tokio::test]
async fn a_reported_session_id_is_recorded_on_the_session() {
let fx = fixture(Duration::from_secs(300)).await;
let id = spawn_sleeping(&fx.manager).await;
fx.manager
.report_activity(id, ActivityState::Active, None, Some("conv-abc123".into()))
.await
.unwrap();
let s = observed(&fx.manager, id).await;
assert_eq!(s.agent_session_id.as_deref(), Some("conv-abc123"));
}
/// #161: repeated reports of the same id are pure churn from the hook's
/// `PreToolUse` cadence — the sidecar must not be rewritten for them.
#[tokio::test]
async fn a_repeated_report_of_the_same_session_id_writes_nothing() {
let fx = fixture(Duration::from_secs(300)).await;
let id = spawn_sleeping(&fx.manager).await;
fx.manager
.report_activity(id, ActivityState::Active, None, Some("conv-first".into()))
.await
.unwrap();
fx.manager
.report_activity(id, ActivityState::Active, None, Some("conv-first".into()))
.await
.unwrap();
let s = observed(&fx.manager, id).await;
assert_eq!(s.agent_session_id.as_deref(), Some("conv-first"));
}
/// #176: a resume issues a new conversation id. The stored id must move to
/// it, or every later resume reattaches the stale pre-resume conversation.
#[tokio::test]
async fn a_reported_session_id_is_replaced_when_it_differs() {
let fx = fixture(Duration::from_secs(300)).await;
let id = spawn_sleeping(&fx.manager).await;
fx.manager
.report_activity(id, ActivityState::Active, None, Some("conv-first".into()))
.await
.unwrap();
fx.manager
.report_activity(id, ActivityState::Active, None, Some("conv-second".into()))
.await
.unwrap();
let s = observed(&fx.manager, id).await;
assert_eq!(s.agent_session_id.as_deref(), Some("conv-second"));
let stored = fx
.sidecar
.read(id)
.expect("sidecar read")
.expect("sidecar present");
assert_eq!(stored.agent_session_id.as_deref(), Some("conv-second"));
}
#[tokio::test]
async fn a_reported_session_id_survives_recovery_from_the_sidecar() {
let fx = fixture(Duration::from_secs(300)).await;
let id = spawn_sleeping(&fx.manager).await;
fx.manager
.report_activity(id, ActivityState::Active, None, Some("conv-abc123".into()))
.await
.unwrap();
let stored = fx
.sidecar
.read(id)
.expect("sidecar read")
.expect("sidecar present");
assert_eq!(stored.agent_session_id.as_deref(), Some("conv-abc123"));
// The sidecar holding the id is only half the claim: a restarted daemon
// has to carry it back onto the recovered session, which is what a resume
// reads. Build a parallel manager over the same tmux server and state
// dirs and recover through it.
let revived = SessionManager::new(
Arc::new(fx._tmux.tmux.clone()),
fx.sidecar.clone(),
WorktreeManager::new(None),
fx._state_dir.path().join("fifos"),
);
revived.recover().await.expect("recover");
let recovered = observed(&revived, id).await;
assert_eq!(
recovered.agent_session_id.as_deref(),
Some("conv-abc123"),
"the recovered session must carry the agent session id, not just the sidecar"
);
}
-68
View File
@@ -94,74 +94,6 @@ async fn rejects_unknown_session() {
assert!(format!("{err}").contains("404"));
}
/// #166: a session the daemon knows but has no bridge for is not unknown, it
/// is not running. `404 unknown_session` made "gone" and "resumable" look the
/// same to a client that could have offered a resume.
#[tokio::test]
async fn dormant_session_is_not_attachable_rather_than_unknown() {
let h = spawn_daemon_ws().await;
let cwd = TempDir::new().unwrap();
let session = h
.manager
.create(CreateSessionRequest {
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.unwrap();
// The host reboot: tmux is gone, the sidecar is not, and a fresh manager
// registers the session dormant.
h._fx.tmux.tmux.kill_server().unwrap();
let dormant = SessionManager::new(
std::sync::Arc::new(h._fx.tmux.tmux.clone()),
maestro_daemon::sidecar::SidecarStore::new(h._fx.sidecar_dir()),
maestro_daemon::worktree::WorktreeManager::new(None),
h._fx.fifo_dir(),
);
dormant.recover().await.unwrap();
let dormant_addr = serve_manager(dormant).await;
let err = connect_attach(dormant_addr, session.id).await.unwrap_err();
let tokio_tungstenite::tungstenite::Error::Http(resp) = err else {
panic!("want an HTTP rejection, got {err}");
};
assert_eq!(resp.status().as_u16(), 409);
let body: maestro_protocol::ApiError =
serde_json::from_slice(resp.body().as_deref().expect("error body")).unwrap();
assert_eq!(body.error.code, "not_attachable");
// A session that really is unknown still reads as unknown.
let err = connect_attach(dormant_addr, Uuid::new_v4())
.await
.unwrap_err();
assert!(format!("{err}").contains("404"));
}
/// Serve `manager` on its own listener, for tests that need a second daemon
/// over the same tmux/sidecar state.
async fn serve_manager(manager: SessionManager) -> SocketAddr {
let router = local_api::router(local_api::state_for(
manager,
"test-host",
"/home/test",
Vec::new(),
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
maestro_config::DEFAULT_LEADER,
));
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await
.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let _ = axum::serve(listener, router).await;
});
addr
}
#[tokio::test]
async fn pty_round_trip_through_real_tmux() {
let h = spawn_daemon_ws().await;
@@ -14,7 +14,7 @@ use std::process::Command;
use std::time::Duration;
use common::{init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture};
use maestro_protocol::{CreateSessionRequest, WorktreeSpec};
use maestro_protocol::{CreateSessionRequest, InTheWay, WorktreeSpec};
use tempfile::TempDir;
fn request(cwd: &std::path::Path, repo: &std::path::Path, branch: &str) -> CreateSessionRequest {
@@ -91,12 +91,13 @@ async fn a_session_without_a_worktree_costs_no_probe() {
Duration::from_millis(100),
);
// 30s is a hang guard only, not the property under test — that's the
// timing-free `contains` check below.
wait_for_async(Duration::from_secs(30), || async {
std::fs::read_to_string(&log_path)
.map(|calls| calls.contains(repo_a.path().to_str().unwrap()))
.unwrap_or(false)
// The pass demonstrably runs and probed A's tree...
wait_for_async(Duration::from_secs(5), || async {
fx.manager
.snapshot()
.await
.iter()
.any(|s| s.worktree.is_some() && s.blocked_work == Some(InTheWay::default()))
})
.await;
+4 -21
View File
@@ -151,40 +151,27 @@ pub struct DaemonFixture {
impl DaemonFixture {
pub fn new() -> Self {
Self::build(None, maestro_daemon::pty::DEFAULT_REPLAY_BYTES)
}
/// Variant whose manager runs a custom PTY replay budget (NAP-229), which
/// also caps the scrollback it persists (#171).
pub fn new_with_replay(replay_bytes: usize) -> Self {
Self::build(None, replay_bytes)
Self::build(None)
}
/// Variant whose manager has a durable spawn history (#24) attached, with
/// `retention_days` recency cap, logging to `<state_dir>/spawn-history.jsonl`.
pub fn new_with_history(retention_days: u32) -> Self {
Self::build(
Some(retention_days),
maestro_daemon::pty::DEFAULT_REPLAY_BYTES,
)
Self::build(Some(retention_days))
}
fn build(history_days: Option<u32>, replay_bytes: usize) -> Self {
fn build(history_days: Option<u32>) -> Self {
let tmux = TmuxFixture::new();
let state_dir = TempDir::new().unwrap();
let sidecar = SidecarStore::new(state_dir.path().join("sessions"));
// Default placement: worktrees land under their base repo (#56).
let worktree_mgr = WorktreeManager::new(None);
let mut manager = SessionManager::new_with_replay(
let mut manager = SessionManager::new(
Arc::new(tmux.tmux.clone()),
sidecar,
worktree_mgr,
state_dir.path().join("fifos"),
replay_bytes,
);
// #171: production wires this, so the fixtures do too — every test
// then exercises the same flush/restore/delete paths the daemon runs.
manager = manager.with_scrollback(state_dir.path().join("scrollback"));
if let Some(days) = history_days {
manager =
manager.with_spawn_history(SpawnHistory::new(Self::history_path(&state_dir), days));
@@ -211,8 +198,4 @@ impl DaemonFixture {
pub fn fifo_dir(&self) -> PathBuf {
self.state_dir.path().join("fifos")
}
pub fn scrollback_dir(&self) -> PathBuf {
self.state_dir.path().join("scrollback")
}
}
-27
View File
@@ -526,33 +526,6 @@ async fn post_activity_reports_error_with_detail() {
assert_eq!(s.activity_detail.as_deref(), Some("cargo build failed"));
}
/// #161: a reported `session_id` lands on the session as `agent_session_id`.
#[tokio::test]
async fn post_activity_records_the_reported_session_id() {
let h = spawn("naps-laptop").await;
let id = spawn_via_manager(&h).await;
let resp = reqwest::Client::new()
.post(url(h.addr, &format!("/api/sessions/{id}/activity")))
.json(&serde_json::json!({
"state": "Active",
"session_id": "conv-abc123"
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT);
let sessions: Vec<Session> = reqwest::get(url(h.addr, "/api/sessions"))
.await
.unwrap()
.json()
.await
.unwrap();
let s = sessions.iter().find(|s| s.id == id).unwrap();
assert_eq!(s.agent_session_id.as_deref(), Some("conv-abc123"));
}
#[tokio::test]
async fn post_activity_to_unknown_session_returns_404() {
let h = spawn("naps-laptop").await;
-593
View File
@@ -1,593 +0,0 @@
//! End-to-end tests for `POST /api/sessions/:id/resume` (#166) and the
//! manager call behind it, against real tmux.
//!
//! The point of resume is that the session comes back under the *same* uuid
//! and sidecar — name, `created_at`, env and worktree ownership intact — with
//! only the tmux session and the PTY bridge new.
mod common;
use std::collections::HashMap;
use std::net::{Ipv4Addr, SocketAddr};
use std::sync::Arc;
use std::time::Duration;
use common::{wait_for_async, DaemonFixture};
use maestro_daemon::{local_api, SessionError, SessionManager};
use maestro_protocol::{CreateSessionRequest, Session, SessionStatus, SpawnPreset};
use tempfile::TempDir;
use tokio::net::TcpListener;
use tokio::sync::oneshot;
use uuid::Uuid;
/// A fresh manager over the same tmux server, sidecars and fifos — what the
/// daemon builds after a restart.
fn revive(fx: &DaemonFixture) -> SessionManager {
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(),
)
}
/// Create a session, then simulate the host reboot that leaves it dormant:
/// the tmux server dies, the sidecar stays, a new manager recovers.
async fn dormant_session(
fx: &DaemonFixture,
cwd: &TempDir,
command: &str,
name: &str,
) -> (SessionManager, Session) {
let mut metadata = HashMap::new();
metadata.insert(
maestro_protocol::NAME_KEY.to_string(),
serde_json::Value::String(name.into()),
);
let session = fx
.manager
.create(CreateSessionRequest {
cwd: cwd.path().to_path_buf(),
command: command.into(),
env: HashMap::from([("MAESTRO_TEST_ENV".to_string(), "kept".to_string())]),
metadata,
worktree: None,
})
.await
.expect("create");
fx.tmux.tmux.kill_server().expect("kill tmux server");
let revived = revive(fx);
revived.recover().await.expect("recover");
(revived, session)
}
fn marker_preset(command: &str, marker_dir: &TempDir) -> Vec<SpawnPreset> {
vec![SpawnPreset {
id: "agent".into(),
label: "Agent".into(),
command: command.into(),
resume_command: Some(format!(
"sh -c 'touch {}/resumed-{{agent_session}}; sleep 30'",
marker_dir.path().display()
)),
}]
}
/// The keystone: a dormant session comes back running under its own id, with
/// its name, `created_at`, env and sidecar untouched.
#[tokio::test]
async fn resume_brings_a_dormant_session_back_under_the_same_id() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let (manager, original) = dormant_session(&fx, &cwd, "sleep 30", "long-runner").await;
assert_eq!(
manager.status(original.id).await,
Some(SessionStatus::Dormant)
);
let resumed = manager.resume(original.id, &[]).await.expect("resume");
assert_eq!(resumed.id, original.id, "same uuid");
assert_eq!(resumed.status, SessionStatus::Running);
assert_eq!(resumed.created_at, original.created_at);
assert_eq!(resumed.cwd, original.cwd);
assert_eq!(resumed.command, original.command);
assert_eq!(resumed.env, original.env);
assert_eq!(
resumed
.metadata
.get(maestro_protocol::NAME_KEY)
.and_then(|v| v.as_str()),
Some("long-runner")
);
// tmux really holds it again, and the sidecar is the same file, updated.
let live = fx.tmux.tmux.list_sessions().expect("list");
assert_eq!(live, vec![original.id]);
let stored = maestro_daemon::sidecar::SidecarStore::new(fx.sidecar_dir())
.read(original.id)
.expect("read sidecar")
.expect("sidecar still there");
assert_eq!(stored.status, SessionStatus::Running);
assert_eq!(stored.created_at, original.created_at);
// And it is attachable again, which a dormant session never was.
assert!(manager
.subscribe_pty_with_replay(original.id)
.await
.is_some());
}
/// The worktree the session owns stays the session's, so killing the resumed
/// session still cleans the tree up.
#[tokio::test]
async fn resume_keeps_worktree_ownership() {
let fx = DaemonFixture::new();
let repo = TempDir::new().unwrap();
common::init_repo(repo.path());
let session = fx
.manager
.create(CreateSessionRequest {
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(maestro_protocol::WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feature".into(),
create_branch: true,
}),
})
.await
.expect("create");
let worktree = session.worktree.clone().expect("worktree");
fx.tmux.tmux.kill_server().expect("kill tmux server");
let manager = revive(&fx);
manager.recover().await.expect("recover");
let resumed = manager.resume(session.id, &[]).await.expect("resume");
assert_eq!(resumed.worktree, Some(worktree.clone()));
assert_eq!(
resumed.cwd, worktree.path,
"resumed inside its own worktree"
);
}
/// The command comes from the preset's `resume_command`, with the session's
/// own agent conversation id (#161) substituted in.
#[tokio::test]
async fn resume_runs_the_presets_resume_command() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let markers = TempDir::new().unwrap();
let presets = marker_preset("sleep 30", &markers);
let (manager, session) = dormant_session(&fx, &cwd, "sleep 30", "agentic").await;
// The agent reported its conversation id before the reboot, so it is on
// the sidecar and the resume template can be filled in.
fx.manager
.report_activity(
session.id,
maestro_protocol::ActivityState::Idle,
None,
Some("conv-42".into()),
)
.await
.ok();
// Re-read the sidecar the report updated.
let manager = {
drop(manager);
let m = revive(&fx);
m.recover().await.expect("recover");
m
};
manager.resume(session.id, &presets).await.expect("resume");
let marker = markers.path().join("resumed-conv-42");
wait_for_async(Duration::from_secs(5), || {
let marker = marker.clone();
async move { marker.exists() }
})
.await;
}
/// A session that is already running has nothing to bring back, and
/// respawning would strand the pane that is already there.
#[tokio::test]
async fn resume_refuses_a_running_session() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let session = 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");
let err = fx
.manager
.resume(session.id, &[])
.await
.expect_err("already running");
assert!(matches!(err, SessionError::AlreadyRunning(id) if id == session.id));
assert_eq!(err.to_error_info().code, "already_running");
}
/// Nothing can be resumed in a directory that no longer exists.
#[tokio::test]
async fn resume_refuses_when_the_cwd_is_gone() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let (manager, session) = dormant_session(&fx, &cwd, "sleep 30", "homeless").await;
std::fs::remove_dir_all(cwd.path()).unwrap();
let err = manager
.resume(session.id, &[])
.await
.expect_err("cwd is gone");
assert_eq!(err.to_error_info().code, "cwd_missing");
assert_eq!(
manager.status(session.id).await,
Some(SessionStatus::Dormant),
"a refused resume leaves the session exactly as it was"
);
}
#[tokio::test]
async fn resume_of_an_unknown_session_is_not_found() {
let fx = DaemonFixture::new();
let err = fx
.manager
.resume(Uuid::new_v4(), &[])
.await
.expect_err("unknown");
assert_eq!(err.to_error_info().code, "not_found");
}
// ---------------------------------------------------------------------------
// HTTP surface
// ---------------------------------------------------------------------------
struct Harness {
addr: SocketAddr,
_shutdown: oneshot::Sender<()>,
}
async fn serve(manager: SessionManager, presets: Vec<SpawnPreset>) -> Harness {
let router = local_api::router(local_api::state_for(
manager,
"test-host",
"/home/test",
presets,
Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
maestro_config::DEFAULT_LEADER,
));
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await
.unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = oneshot::channel::<()>();
tokio::spawn(async move {
let _ = axum::serve(listener, router)
.with_graceful_shutdown(async move {
let _ = rx.await;
})
.await;
});
Harness {
addr,
_shutdown: tx,
}
}
#[tokio::test]
async fn post_resume_returns_the_revived_session() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let (manager, session) = dormant_session(&fx, &cwd, "sleep 30", "web-resumed").await;
let h = serve(manager, Vec::new()).await;
let resp = reqwest::Client::new()
.post(format!(
"http://{}{}",
h.addr,
maestro_protocol::routes::session_resume(session.id)
))
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::OK);
let body: Session = resp.json().await.unwrap();
assert_eq!(body.id, session.id);
assert_eq!(body.status, SessionStatus::Running);
}
#[tokio::test]
async fn post_resume_on_a_running_session_is_a_conflict() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let session = 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");
let h = serve(fx.manager.clone(), Vec::new()).await;
let resp = reqwest::Client::new()
.post(format!(
"http://{}{}",
h.addr,
maestro_protocol::routes::session_resume(session.id)
))
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::CONFLICT);
let err: maestro_protocol::ApiError = resp.json().await.unwrap();
assert_eq!(err.error.code, "already_running");
}
#[tokio::test]
async fn post_resume_on_an_unknown_session_is_404() {
let fx = DaemonFixture::new();
let h = serve(fx.manager.clone(), Vec::new()).await;
let resp = reqwest::Client::new()
.post(format!(
"http://{}{}",
h.addr,
maestro_protocol::routes::session_resume(Uuid::new_v4())
))
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
}
// ---------------------------------------------------------------------------
// Races (#175)
// ---------------------------------------------------------------------------
/// Real tmux, with the *first* `new_session_piped` held inside the call until
/// the test releases it. Later calls pass straight through, so a second resume
/// racing the first never blocks on a gate the first one owns.
///
/// This is what makes the two races below deterministic rather than timing
/// dependent: the resume under test is parked in exactly the window the bug
/// lives in, and the racing operation runs to completion before it moves.
#[derive(Debug)]
struct GatedSpawn {
inner: maestro_daemon::tmux::Tmux,
entered: tokio::sync::mpsc::UnboundedSender<()>,
release: std::sync::Mutex<std::sync::mpsc::Receiver<()>>,
gated: std::sync::atomic::AtomicBool,
}
/// The test's end of a [`GatedSpawn`].
struct Gate {
entered: tokio::sync::mpsc::UnboundedReceiver<()>,
release: std::sync::mpsc::SyncSender<()>,
}
impl Gate {
/// Wait until the gated spawn is parked mid-resume.
async fn wait_entered(&mut self) {
tokio::time::timeout(Duration::from_secs(10), self.entered.recv())
.await
.expect("resume reached the tmux spawn")
.expect("gate alive");
}
fn release(&self) {
self.release.send(()).expect("gate receiver alive");
}
}
fn gated_panes(inner: maestro_daemon::tmux::Tmux) -> (Arc<GatedSpawn>, Gate) {
let (entered_tx, entered_rx) = tokio::sync::mpsc::unbounded_channel();
let (release_tx, release_rx) = std::sync::mpsc::sync_channel(1);
(
Arc::new(GatedSpawn {
inner,
entered: entered_tx,
release: std::sync::Mutex::new(release_rx),
gated: std::sync::atomic::AtomicBool::new(true),
}),
Gate {
entered: entered_rx,
release: release_tx,
},
)
}
impl maestro_daemon::panes::Panes for GatedSpawn {
fn new_session_piped(
&self,
id: Uuid,
cwd: &std::path::Path,
command: &str,
session_env: &std::collections::BTreeMap<String, String>,
sink_command: &str,
) -> Result<(), maestro_daemon::tmux::TmuxError> {
if self.gated.swap(false, std::sync::atomic::Ordering::SeqCst) {
let _ = self.entered.send(());
let _ = self.release.lock().expect("gate mutex").recv();
}
self.inner
.new_session_piped(id, cwd, command, session_env, sink_command)
}
fn kill_session(&self, id: Uuid) -> Result<(), maestro_daemon::tmux::TmuxError> {
self.inner.kill_session(id)
}
fn list_sessions(&self) -> Result<Vec<Uuid>, maestro_daemon::tmux::TmuxError> {
self.inner.list_sessions()
}
fn pane_info(
&self,
id: Uuid,
) -> Result<Option<maestro_daemon::tmux::PaneInfo>, maestro_daemon::tmux::TmuxError> {
self.inner.pane_info(id)
}
fn send_keys(&self, id: Uuid, bytes: &[u8]) -> Result<(), maestro_daemon::tmux::TmuxError> {
self.inner.send_keys(id, bytes)
}
fn pipe_pane(&self, id: Uuid, sink: &str) -> Result<(), maestro_daemon::tmux::TmuxError> {
self.inner.pipe_pane(id, sink)
}
fn resize_window(
&self,
id: Uuid,
cols: u16,
rows: u16,
) -> Result<(), maestro_daemon::tmux::TmuxError> {
self.inner.resize_window(id, cols, rows)
}
fn capture_pane(&self, id: Uuid) -> Result<Option<Vec<u8>>, maestro_daemon::tmux::TmuxError> {
self.inner.capture_pane(id)
}
}
/// A dormant session whose manager's tmux spawn can be parked by the test.
async fn dormant_gated(fx: &DaemonFixture, cwd: &TempDir) -> (SessionManager, Session, Gate) {
let session = 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 (panes, gate) = gated_panes(fx.tmux.tmux.clone());
let manager = SessionManager::new(
panes,
maestro_daemon::sidecar::SidecarStore::new(fx.sidecar_dir()),
maestro_daemon::worktree::WorktreeManager::new(None),
fx.fifo_dir(),
);
manager.recover().await.expect("recover");
assert_eq!(
manager.status(session.id).await,
Some(SessionStatus::Dormant)
);
(manager, session, gate)
}
/// A kill that lands while a resume is inside the tmux spawn must not leave
/// the sidecar behind. The kill deletes it; the resume writes it back a moment
/// later and then discovers its own record is gone. The sidecar it resurrected
/// would otherwise make the next recovery register a ghost dormant session for
/// a session that was killed.
#[tokio::test]
async fn a_kill_during_resume_leaves_no_sidecar_behind() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let (manager, session, mut gate) = dormant_gated(&fx, &cwd).await;
let id = session.id;
let resuming = tokio::spawn({
let manager = manager.clone();
async move { manager.resume(id, &[]).await }
});
gate.wait_entered().await;
manager.kill(id, false).await.expect("kill mid-resume");
gate.release();
let err = resuming.await.unwrap().expect_err("record is gone");
assert!(matches!(err, SessionError::NotFound(got) if got == id));
assert!(
maestro_daemon::sidecar::SidecarStore::new(fx.sidecar_dir())
.read(id)
.expect("read sidecar")
.is_none(),
"the killed session's sidecar must not be resurrected by the losing resume"
);
// And so the next recovery registers no ghost.
let after = revive(&fx);
after.recover().await.expect("recover");
assert!(
after.snapshot().await.is_empty(),
"a killed session must not come back dormant"
);
}
/// Two resumes of the same session must not both proceed. The second is
/// refused on the state — `already_running` — not on its tmux spawn colliding
/// with the first one's.
#[tokio::test]
async fn a_second_resume_is_refused_while_the_first_is_in_flight() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let (manager, session, mut gate) = dormant_gated(&fx, &cwd).await;
let id = session.id;
let resuming = tokio::spawn({
let manager = manager.clone();
async move { manager.resume(id, &[]).await }
});
gate.wait_entered().await;
let err = manager
.resume(id, &[])
.await
.expect_err("a resume is already in flight");
assert!(matches!(err, SessionError::AlreadyRunning(got) if got == id));
assert_eq!(err.to_error_info().code, "already_running");
gate.release();
let first = resuming.await.unwrap().expect("the first resume wins");
assert_eq!(first.status, SessionStatus::Running);
assert_eq!(
fx.tmux.tmux.list_sessions().expect("list"),
vec![id],
"exactly one pane for the session"
);
}
/// A refused resume must not leave the session marked in-flight: once the
/// reason for the refusal is gone, resuming works.
#[tokio::test]
async fn a_refused_resume_can_be_retried() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let (manager, session) = dormant_session(&fx, &cwd, "sleep 30", "retried").await;
std::fs::remove_dir_all(cwd.path()).unwrap();
let err = manager
.resume(session.id, &[])
.await
.expect_err("cwd is gone");
assert_eq!(err.to_error_info().code, "cwd_missing");
std::fs::create_dir_all(cwd.path()).unwrap();
let resumed = manager.resume(session.id, &[]).await.expect("resume");
assert_eq!(resumed.status, SessionStatus::Running);
}
@@ -1,161 +0,0 @@
//! `[daemon].resume_on_boot` (#167): what recovery does with sessions whose
//! tmux pane is gone but whose sidecar survived, against real tmux.
//!
//! The default is deliberately the timid one. Resuming agents unattended
//! seconds after a reboot is a foot-gun, so `auto` is opted into per host and
//! `never` throws the sidecars away instead.
mod common;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use common::{wait_for_async, DaemonFixture};
use maestro_config::ResumeOnBoot;
use maestro_daemon::SessionManager;
use maestro_protocol::{CreateSessionRequest, Session, SessionStatus, SpawnPreset};
use tempfile::TempDir;
/// A fresh manager over the same tmux server, sidecars and fifos — what the
/// daemon builds after a restart — carrying `policy`.
fn revive(fx: &DaemonFixture, policy: ResumeOnBoot, presets: Vec<SpawnPreset>) -> SessionManager {
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(),
)
.with_resume_policy(policy, presets)
}
/// A preset whose `resume_command` touches a marker file, so a test can see
/// that the resumed session ran the resume command rather than the original.
fn marker_preset(command: &str, marker_dir: &TempDir) -> Vec<SpawnPreset> {
vec![SpawnPreset {
id: "agent".into(),
label: "Agent".into(),
command: command.into(),
resume_command: Some(format!(
"sh -c 'touch {}/resumed; sleep 30'",
marker_dir.path().display()
)),
}]
}
async fn create(fx: &DaemonFixture, cwd: &TempDir, command: &str) -> Session {
fx.manager
.create(CreateSessionRequest {
cwd: cwd.path().to_path_buf(),
command: command.into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.expect("create")
}
/// The default: a reboot leaves the session listed but not running, waiting
/// for the operator.
#[tokio::test]
async fn dormant_is_the_default_and_leaves_the_session_waiting() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let session = create(&fx, &cwd, "sleep 30").await;
fx.tmux.tmux.kill_server().expect("kill tmux server");
let revived = revive(&fx, ResumeOnBoot::Dormant, Vec::new());
revived.recover().await.expect("recover");
assert_eq!(
revived.status(session.id).await,
Some(SessionStatus::Dormant)
);
assert!(fx
.sidecar_dir()
.join(format!("{}.json", session.id))
.exists());
assert!(
fx.tmux.tmux.list_sessions().expect("list").is_empty(),
"nothing was respawned"
);
}
/// `auto` brings each one back itself, down the same path a client's resume
/// takes: same uuid, same sidecar, the preset's `resume_command`.
#[tokio::test]
async fn auto_resumes_every_dormant_session_during_recovery() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let markers = TempDir::new().unwrap();
let session = create(&fx, &cwd, "sleep 30").await;
fx.tmux.tmux.kill_server().expect("kill tmux server");
let revived = revive(&fx, ResumeOnBoot::Auto, marker_preset("sleep 30", &markers));
revived.recover().await.expect("recover");
assert_eq!(
revived.status(session.id).await,
Some(SessionStatus::Running),
"auto resumed it"
);
assert_eq!(
fx.tmux.tmux.list_sessions().expect("list"),
vec![session.id],
"back under the same uuid"
);
let stored = maestro_daemon::sidecar::SidecarStore::new(fx.sidecar_dir())
.read(session.id)
.expect("read sidecar")
.expect("same sidecar");
assert_eq!(stored.created_at, session.created_at);
assert_eq!(stored.command, "sleep 30", "the original command is kept");
// It really ran the preset's resume command, not the spawn command.
let marker = markers.path().join("resumed");
wait_for_async(Duration::from_secs(5), || {
let marker = marker.clone();
async move { marker.exists() }
})
.await;
assert!(marker.exists(), "resume_command ran");
}
/// `never` makes a reboot a clean slate: the sidecars go, and the session is
/// not listed at all.
#[tokio::test]
async fn never_prunes_the_sidecars_instead() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let session = create(&fx, &cwd, "sleep 30").await;
fx.tmux.tmux.kill_server().expect("kill tmux server");
let revived = revive(&fx, ResumeOnBoot::Never, Vec::new());
revived.recover().await.expect("recover");
assert!(!revived.has(session.id).await, "not listed");
assert!(
!fx.sidecar_dir()
.join(format!("{}.json", session.id))
.exists(),
"sidecar pruned"
);
assert!(fx.tmux.tmux.list_sessions().expect("list").is_empty());
}
/// The policy only decides what happens to sessions that lost their pane. A
/// session tmux still holds is adopted running by the first pass under every
/// policy — including `never`, which must not delete a live session's sidecar.
#[tokio::test]
async fn the_policy_leaves_sessions_tmux_still_holds_alone() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let live = create(&fx, &cwd, "sleep 30").await;
let revived = revive(&fx, ResumeOnBoot::Never, Vec::new());
assert_eq!(revived.recover().await.expect("recover"), 1);
assert_eq!(revived.status(live.id).await, Some(SessionStatus::Running));
assert!(fx.sidecar_dir().join(format!("{}.json", live.id)).exists());
}
-219
View File
@@ -1,219 +0,0 @@
//! Persisted PTY scrollback across a reboot (#171), against real tmux.
//!
//! Everything else in the reboot milestone restores what a session *is*; this
//! restores what it *looked like*. The cases that matter are the ones a clean
//! shutdown never gets to run: the host went down, and the only scrollback
//! that exists is whatever the last periodic flush wrote.
mod common;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use common::{wait_for_async, DaemonFixture};
use maestro_daemon::scrollback::ScrollbackStore;
use maestro_daemon::SessionManager;
use maestro_protocol::{CreateSessionRequest, Session, SessionStatus};
use tempfile::TempDir;
use uuid::Uuid;
/// A fresh manager over the same tmux server, sidecars, fifos and scrollback —
/// what the daemon builds after a restart.
fn revive(fx: &DaemonFixture) -> SessionManager {
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(),
)
.with_scrollback(fx.scrollback_dir())
}
async fn spawn(fx: &DaemonFixture, cwd: &TempDir, command: &str) -> Session {
fx.manager
.create(CreateSessionRequest {
cwd: cwd.path().to_path_buf(),
command: command.into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.expect("create")
}
/// Wait until the session has printed `needle`, flushing as we go — the same
/// thing the liveness watcher's flush pass does on its timer.
async fn wait_for_flushed(
manager: &SessionManager,
store: &ScrollbackStore,
id: Uuid,
needle: &str,
) {
wait_for_async(Duration::from_secs(10), || async {
manager.flush_scrollback().await;
String::from_utf8_lossy(&store.read(id, 64 * 1024)).contains(needle)
})
.await;
}
fn replay_text(replay: &[u8]) -> String {
String::from_utf8_lossy(replay).to_string()
}
/// The keystone: the host reboots with no exit path run at all, and the
/// resumed session opens showing what it printed before.
#[tokio::test]
async fn a_resumed_session_replays_the_scrollback_from_before_the_reboot() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let store = ScrollbackStore::new(fx.scrollback_dir());
let session = spawn(&fx, &cwd, "sh -c 'echo before-the-reboot; sleep 30'").await;
wait_for_flushed(&fx.manager, &store, session.id, "before-the-reboot").await;
// The reboot: tmux dies where it stands. No kill, no seal, no shutdown
// hook — only whatever the periodic flush had already written.
fx.tmux.tmux.kill_server().expect("kill tmux server");
let manager = revive(&fx);
manager.recover().await.expect("recover");
assert_eq!(
manager.status(session.id).await,
Some(SessionStatus::Dormant)
);
manager.resume(session.id, &[]).await.expect("resume");
let (replay, _rx) = manager
.subscribe_pty_with_replay(session.id)
.await
.expect("attachable again");
assert!(
replay_text(&replay).contains("before-the-reboot"),
"resumed pane should open on the old scrollback, got {:?}",
replay_text(&replay)
);
}
/// A daemon restart leaves tmux alone, so the session is adopted rather than
/// resumed — and it too lost its in-memory ring with the old process.
#[tokio::test]
async fn an_adopted_session_replays_the_scrollback_of_the_previous_daemon() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let store = ScrollbackStore::new(fx.scrollback_dir());
let session = spawn(&fx, &cwd, "sh -c 'echo printed-earlier; sleep 30'").await;
wait_for_flushed(&fx.manager, &store, session.id, "printed-earlier").await;
// Daemon restart: the manager goes, tmux stays.
let manager = revive(&fx);
manager.recover().await.expect("recover");
assert_eq!(
manager.status(session.id).await,
Some(SessionStatus::Running)
);
let (replay, _rx) = manager
.subscribe_pty_with_replay(session.id)
.await
.expect("attachable");
assert!(
replay_text(&replay).contains("printed-earlier"),
"adopted pane should carry the old scrollback ahead of tmux's screen, got {:?}",
replay_text(&replay)
);
}
/// Killing a session takes its scrollback with it: nothing will ever read the
/// file again, and the operator's output should not linger on disk.
#[tokio::test]
async fn killing_a_session_deletes_its_scrollback() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let store = ScrollbackStore::new(fx.scrollback_dir());
let session = spawn(&fx, &cwd, "sh -c 'echo doomed; sleep 30'").await;
wait_for_flushed(&fx.manager, &store, session.id, "doomed").await;
assert!(store.path_for(session.id).exists());
fx.manager.kill(session.id, false).await.expect("kill");
assert!(
!store.path_for(session.id).exists(),
"scrollback should not outlive the session"
);
}
/// A session that exits on its own does get an exit path, and takes it: the
/// last thing it printed is flushed when the bridge is sealed, with no wait
/// for the next tick.
#[tokio::test]
async fn a_session_that_exits_flushes_its_last_output() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let store = ScrollbackStore::new(fx.scrollback_dir());
let session = spawn(&fx, &cwd, "sh -c 'echo last-words'").await;
fx.manager
.start_liveness_watcher(Duration::from_millis(100));
wait_for_async(Duration::from_secs(10), || async {
fx.manager.status(session.id).await == Some(SessionStatus::Dead)
})
.await;
assert!(
String::from_utf8_lossy(&store.read(session.id, 64 * 1024)).contains("last-words"),
"a sealed bridge should have flushed what it printed"
);
}
/// Nothing here may fail a resume. A scrollback file that is gone, or one full
/// of bytes no terminal would emit, degrades to a pane with no history — not
/// to an error.
#[tokio::test]
async fn a_missing_or_corrupt_scrollback_still_resumes() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let store = ScrollbackStore::new(fx.scrollback_dir());
let gone = spawn(&fx, &cwd, "sleep 30").await;
let garbled = spawn(&fx, &cwd, "sleep 30").await;
fx.manager.flush_scrollback().await;
fx.tmux.tmux.kill_server().expect("kill tmux server");
std::fs::create_dir_all(store.dir()).expect("scrollback dir");
std::fs::remove_file(store.path_for(gone.id)).ok();
std::fs::write(store.path_for(garbled.id), [0xff, 0xfe, 0x00, 0x80]).expect("write garbage");
let manager = revive(&fx);
manager.recover().await.expect("recover");
for id in [gone.id, garbled.id] {
manager.resume(id, &[]).await.expect("resume");
assert_eq!(manager.status(id).await, Some(SessionStatus::Running));
assert!(manager.subscribe_pty_with_replay(id).await.is_some());
}
}
/// The disk cost is the replay budget, not a second one of its own.
#[tokio::test]
async fn the_flushed_file_stays_inside_the_replay_budget() {
let fx = DaemonFixture::new_with_replay(256);
let cwd = TempDir::new().unwrap();
let store = ScrollbackStore::new(fx.scrollback_dir());
let session = spawn(
&fx,
&cwd,
"sh -c 'for i in $(seq 1 400); do echo line-$i; done; sleep 30'",
)
.await;
wait_for_flushed(&fx.manager, &store, session.id, "line-400").await;
let size = std::fs::metadata(store.path_for(session.id))
.expect("stat scrollback")
.len();
assert!(
size <= 256,
"scrollback file was {size} bytes, budget is 256"
);
}
@@ -9,7 +9,6 @@ use std::sync::Arc;
use std::time::Duration;
use common::{init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture};
use maestro_daemon::sidecar::SidecarStore;
use maestro_daemon::SessionError;
use maestro_protocol::CreateSessionRequest;
use maestro_protocol::{CatalogEvent, SessionStatus, WorktreeSpec};
@@ -141,390 +140,6 @@ async fn recover_adopts_existing_tmux_sessions() {
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);
}
/// #163: a sidecar whose `cwd` no longer exists is pruned instead of
/// registered dormant — the worktree it pointed at is gone, so there is
/// nothing left to resume.
#[tokio::test]
async fn recover_deletes_sidecar_when_cwd_gone() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let cwd_path = cwd.path().to_path_buf();
let s = fx
.manager
.create(CreateSessionRequest {
cwd: cwd_path.clone(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.expect("create");
fx.tmux.tmux.kill_server().expect("kill tmux server");
drop(cwd);
std::fs::remove_dir_all(&cwd_path).ok();
assert!(!cwd_path.exists());
let revived = revive(&fx);
let adopted = revived.recover().await.expect("recover");
assert_eq!(adopted, 0);
assert!(!revived.has(s.id).await);
assert!(!fx.sidecar_dir().join(format!("{}.json", s.id)).exists());
}
/// #163: a sidecar whose `cwd` still exists is left alone regardless of age
/// — it is registered dormant like any other orphan.
#[tokio::test]
async fn recover_keeps_dormant_sidecar_when_cwd_exists() {
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");
assert!(revived.has(s.id).await);
assert!(fx.sidecar_dir().join(format!("{}.json", s.id)).exists());
let snap = revived.snapshot().await;
assert_eq!(snap[0].status, SessionStatus::Dormant);
}
/// #164: a fake [`maestro_daemon::boot_id::BootIdSource`] lets tests simulate
/// a changed (or unreadable) boot id without rebooting the machine.
#[derive(Debug)]
struct FakeBootId(Option<String>);
impl maestro_daemon::boot_id::BootIdSource for FakeBootId {
fn current(&self) -> Option<String> {
self.0.clone()
}
}
fn revive_with_boot_id(
fx: &DaemonFixture,
boot_id_path: PathBuf,
current: Option<&str>,
) -> 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(),
)
.with_boot_id(
boot_id_path,
Arc::new(FakeBootId(current.map(String::from))),
)
}
/// #174: a boot id can change without the tmux server dying — a restored
/// state directory, a VM or container snapshot restore, a truncated
/// `boot_id` file. `tmux list-sessions` stays the single source of truth for
/// liveness, so a session tmux still holds is adopted `Running` no matter
/// what the boot id says. Registering it dormant instead stranded it: resume
/// would collide with the tmux name still in use and fail forever.
#[tokio::test]
async fn recover_adopts_live_session_when_boot_id_changed() {
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");
let boot_id_path = fx.state_dir.path().join("boot_id");
std::fs::write(&boot_id_path, "previous-boot").unwrap();
// tmux session is still alive: the probe runs and finds it.
let revived = revive_with_boot_id(&fx, boot_id_path.clone(), Some("current-boot"));
let adopted = revived.recover().await.expect("recover");
assert_eq!(adopted, 1, "tmux still holds the session; adopt it");
let snap = revived.snapshot().await;
assert_eq!(snap.len(), 1);
assert_eq!(snap[0].id, s.id);
assert_eq!(snap[0].status, SessionStatus::Running);
// The current boot id is now the stored one, for the next comparison.
assert_eq!(
std::fs::read_to_string(&boot_id_path).unwrap().trim(),
"current-boot"
);
}
/// #164, still true after #174: a real reboot takes the tmux server with it,
/// so the probe comes back empty and every sidecar is registered dormant.
#[tokio::test]
async fn recover_registers_dormant_when_boot_id_changed_and_tmux_gone() {
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");
let boot_id_path = fx.state_dir.path().join("boot_id");
std::fs::write(&boot_id_path, "previous-boot").unwrap();
fx.tmux.tmux.kill_server().expect("kill tmux server");
let revived = revive_with_boot_id(&fx, boot_id_path, Some("current-boot"));
let adopted = revived.recover().await.expect("recover");
assert_eq!(adopted, 0, "no tmux server survived the reboot");
let snap = revived.snapshot().await;
assert_eq!(snap.len(), 1);
assert_eq!(snap[0].id, s.id);
assert_eq!(snap[0].status, SessionStatus::Dormant);
}
/// #164: an unchanged boot id is a plain restart — recovery probes tmux as
/// before and adopts the still-live session `Running`.
#[tokio::test]
async fn recover_probes_tmux_when_boot_id_unchanged() {
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");
let boot_id_path = fx.state_dir.path().join("boot_id");
std::fs::write(&boot_id_path, "same-boot").unwrap();
let revived = revive_with_boot_id(&fx, boot_id_path, Some("same-boot"));
let adopted = revived.recover().await.expect("recover");
assert_eq!(adopted, 1);
let snap = revived.snapshot().await;
assert_eq!(snap[0].id, s.id);
assert_eq!(snap[0].status, SessionStatus::Running);
}
/// #164: no stored boot id (first run ever, or an older build that never
/// wrote one) means there is nothing to compare against — falls back to
/// today's probe-based recovery rather than assuming a reboot.
#[tokio::test]
async fn recover_falls_back_to_probe_when_no_stored_boot_id() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
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");
let boot_id_path = fx.state_dir.path().join("boot_id");
assert!(!boot_id_path.exists());
let revived = revive_with_boot_id(&fx, boot_id_path.clone(), Some("current-boot"));
let adopted = revived.recover().await.expect("recover");
assert_eq!(adopted, 1, "no stored boot id; falls back to probing tmux");
assert_eq!(
std::fs::read_to_string(&boot_id_path).unwrap().trim(),
"current-boot"
);
}
/// #164: an unreadable current boot id (non-Linux, restricted `/proc`) falls
/// back to today's behaviour rather than failing startup or assuming a
/// reboot.
#[tokio::test]
async fn recover_falls_back_to_probe_when_boot_id_unreadable() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
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");
let boot_id_path = fx.state_dir.path().join("boot_id");
std::fs::write(&boot_id_path, "stored-boot").unwrap();
let revived = revive_with_boot_id(&fx, boot_id_path, None);
let adopted = revived.recover().await.expect("recover");
assert_eq!(adopted, 1, "boot id unreadable; falls back to probing tmux");
}
/// #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
/// writes must reach the replay buffer. Before the fix, `pipe-pane` was a
/// separate tmux invocation that ran after `new-session` + an async FIFO
@@ -1339,65 +954,6 @@ async fn natural_exit_of_a_pristine_worktree_removes_it() {
);
}
/// #172: `mark_session_dead` clears the worktree off the in-memory record
/// when a pristine tree settles, but the local `session` clone it writes to
/// the sidecar and broadcasts must be cleared the same way — otherwise the
/// sidecar keeps naming a tree the daemon just removed, and a recovery from
/// that sidecar advertises a branch and path that no longer exist.
#[tokio::test]
async fn natural_exit_of_a_pristine_worktree_clears_it_from_the_sidecar() {
let fx = DaemonFixture::new();
let repo = TempDir::new().unwrap();
init_repo(repo.path());
let wt_path = repo.path().join("worktrees").join("feat").join("sidecar");
let s = fx
.manager
.create(CreateSessionRequest {
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feat/sidecar".into(),
create_branch: true,
}),
})
.await
.expect("create");
// Push everything so the branch has no unpushed commits: fully pristine.
let origin = TempDir::new().unwrap();
init_bare_repo(origin.path());
push_all_to_origin(repo.path(), origin.path());
fx.tmux.tmux.kill_session(s.id).expect("kill tmux directly");
fx.manager.start_liveness_watcher(Duration::from_millis(50));
wait_for_async(Duration::from_secs(3), || async {
fx.manager.status(s.id).await == Some(SessionStatus::Dead)
})
.await;
wait_for_async(Duration::from_secs(3), || async {
fx.manager.is_settled(s.id).await == Some(true)
})
.await;
assert!(
!wt_path.exists(),
"a pristine worktree leaves nothing behind"
);
let sidecar = SidecarStore::new(fx.sidecar_dir())
.read(s.id)
.expect("sidecar read")
.expect("sidecar exists");
assert!(
sidecar.worktree.is_none(),
"the sidecar must not keep naming a worktree the daemon already removed"
);
}
fn run_git_in(dir: &std::path::Path, args: &[&str]) {
let out = Command::new("git")
.arg("-C")
-1
View File
@@ -32,7 +32,6 @@ fn sample(id: Uuid) -> Session {
current_cwd: Some(PathBuf::from("/tmp/work/crates")),
blocked_work: None,
last_focused_at: None,
agent_session_id: Some("conv-abc123".into()),
}
}
+51
View File
@@ -497,6 +497,57 @@ async fn occupied_path_gets_suffixed_directory_and_both_worktrees_work() {
);
}
/// A random handle can reuse the name of a branch left behind by an earlier,
/// already-removed worktree — `git worktree add -b` refuses outright in that
/// case, even though the directory is free. The daemon suffixes the branch
/// itself (not just the directory) and both land on `-2`.
#[tokio::test(flavor = "multi_thread")]
async fn colliding_branch_name_gets_suffixed_too() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
// No worktree directory in the way, but the branch already exists.
run_git(&["branch", "handle"], base.path());
let s = client
.spawn_session(&worktree_req(base.path(), "handle", true))
.await
.expect("spawn onto a taken branch name");
let suffixed = base.path().join("worktrees").join("handle-2");
assert_eq!(
s.cwd, suffixed,
"the directory must follow the suffixed branch name"
);
assert!(suffixed.is_dir());
let registrations = git_worktree_list(base.path());
assert!(
registrations.iter().any(|l| l.contains("handle-2")),
"the -2 branch's worktree must be registered: {registrations:?}"
);
let branches = String::from_utf8(
std::process::Command::new("git")
.arg("-C")
.arg(base.path())
.args(["branch", "--list"])
.output()
.unwrap()
.stdout,
)
.unwrap();
assert!(
branches.contains("handle-2"),
"the branch itself must be named handle-2: {branches}"
);
let _origin = throwaway_origin(base.path());
client.kill_session(s.id).await.expect("kill");
}
/// #77/#88: the target path holds a plain directory the daemon never made and
/// git has never heard of. The daemon neither adopts it nor errors — it builds
/// beside it and leaves every byte of it alone.
+144 -65
View File
@@ -1,4 +1,4 @@
//! Claude Code activity hook (#106).
//! Agent activity hook (#106).
//!
//! Two halves, both shared by the `maestro hooks install` CLI subcommand and
//! the daemon's first-start auto-install:
@@ -6,28 +6,40 @@
//! * [`HOOK_SCRIPT`] — the hook itself, written to disk at install time. It
//! posts an [`ActivityState`](maestro-protocol) to
//! `$MAESTRO_URL/api/sessions/$MAESTRO_SESSION_ID/activity` (#105). The same
//! global hook runs in every Claude Code session on the machine, so it exits
//! global hook runs in every agent session on the machine, so it exits
//! 0 and silently when either #103 env var is unset, and it never lets a
//! daemon failure escape (short curl timeout, status ignored).
//! * [`install`] — idempotent registration of that script in
//! `~/.claude/settings.json`. Only entries maestro itself wrote (identified
//! by [`HOOK_MARKER`]) are touched; every other hook and every other field
//! in the file is preserved verbatim.
//! * [`install`] — idempotent registration of that script in one [`Target`]'s
//! hook file. Only entries maestro itself wrote (identified by
//! [`HOOK_MARKER`]) are touched; every other hook and every other field in
//! the file is preserved verbatim.
//!
//! One script serves every [`Target`]: Claude Code and Codex read the same
//! `{"hooks": {Event: [{"hooks": [{"type": "command", "command": …}]}]}}`
//! shape and hand the hook the same JSON on stdin. Only the file's location
//! and the event set differ.
use std::path::Path;
/// Marker substring every maestro-installed hook command carries. The
/// installer rewrites or removes exactly these entries — nothing else in the
/// user's settings file.
///
/// It still says `claude` although the script now serves every [`Target`]:
/// the marker is how an install from an older maestro is recognised, and
/// renaming it would orphan every entry already on disk.
pub const HOOK_MARKER: &str = "maestro-claude-hook";
/// File name of the installed script (carries [`HOOK_MARKER`]).
/// File name of the installed script (carries [`HOOK_MARKER`]). One script
/// for every [`Target`] — see the note on [`HOOK_MARKER`] for the name.
pub const SCRIPT_FILE_NAME: &str = "maestro-claude-hook.sh";
/// Hook events installed only with the `haiku-autoname` feature (#131).
/// `UserPromptSubmit` carries the prompt the user just submitted; the script
/// forwards the whole payload to `/api/sessions/:id/prompt`, which names a
/// still-provisionally-named session after its first one.
/// Hook events installed only with the `haiku-autoname` feature (#131), for
/// every [`Target`] — Claude Code and Codex both spell the event
/// `UserPromptSubmit` and both put the submitted text in the payload's
/// `prompt`. The script forwards the whole payload to
/// `/api/sessions/:id/prompt`, which names a still-provisionally-named
/// session after its first one.
#[cfg(feature = "haiku-autoname")]
pub const PROMPT_EVENTS: [(&str, &str); 1] = [("UserPromptSubmit", "Prompt")];
@@ -43,7 +55,7 @@ pub const PROMPT_EVENTS: [(&str, &str); 0] = [];
/// Claude Code's own name for "the turn ended on an API error"; there is no
/// failure signal in `Stop` or `SessionEnd` payloads, so those never report
/// `Error`.
pub const HOOK_EVENTS: [(&str, &str); 5] = [
pub const CLAUDE_EVENTS: [(&str, &str); 5] = [
// Tool starting: the agent is demonstrably producing work, even if the
// tool itself runs for minutes.
("PreToolUse", "Active"),
@@ -58,17 +70,86 @@ pub const HOOK_EVENTS: [(&str, &str); 5] = [
("StopFailure", "Error"),
];
/// Codex hook event → `ActivityState` spelling posted to the daemon.
///
/// Codex's event set is close to Claude Code's but not identical: a pending
/// approval arrives as `PermissionRequest` rather than `Notification`, and
/// there is no `StopFailure`, so a codex session never reports `Error`.
pub const CODEX_EVENTS: [(&str, &str); 4] = [
("PreToolUse", "Active"),
("PostToolUse", "Active"),
// Codex is blocked on the operator approving a tool call.
("PermissionRequest", "AwaitingInput"),
("Stop", "AwaitingInput"),
];
/// An agent whose hook file maestro writes into.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Target {
/// `~/.claude/settings.json`.
ClaudeCode,
/// `~/.codex/hooks.json`. Codex asks the operator to trust a hook it has
/// not seen before, so an install here does nothing until they approve it
/// once inside codex — maestro never writes that approval itself.
Codex,
}
impl Target {
/// Every target an install covers. Both files are written whether or not
/// the agent is on the machine: an unused hook file costs nothing, and
/// the alternative is a session spawned after the daemon started missing
/// the hook because its agent arrived later.
pub const ALL: [Target; 2] = [Target::ClaudeCode, Target::Codex];
/// How the target is named in logs and CLI output.
pub fn label(self) -> &'static str {
match self {
Target::ClaudeCode => "claude-code",
Target::Codex => "codex",
}
}
/// The file this target's hook entries are registered in, under `home`.
pub fn settings_path(self, home: &Path) -> std::path::PathBuf {
match self {
Target::ClaudeCode => home.join(".claude/settings.json"),
Target::Codex => home.join(".codex/hooks.json"),
}
}
/// The activity events this target emits.
pub fn activity_events(self) -> &'static [(&'static str, &'static str)] {
match self {
Target::ClaudeCode => &CLAUDE_EVENTS,
Target::Codex => &CODEX_EVENTS,
}
}
/// Every event installed for this target: its activity events plus, with
/// `haiku-autoname` on, the prompt event both agents spell the same way.
pub fn events(self) -> impl Iterator<Item = (&'static str, &'static str)> {
self.activity_events()
.iter()
.chain(PROMPT_EVENTS.iter())
.copied()
}
}
/// The shipped hook script. POSIX sh so any `/bin/sh` works.
pub const HOOK_SCRIPT: &str = r#"#!/bin/sh
# Installed by maestro (#106): report Claude Code activity to its daemon.
# Exits silently unless running inside a session maestro spawned (#103 env).
# Installed by maestro (#106): report agent activity to its daemon.
# One script for every agent maestro installs hooks into; the state to post
# is chosen by the caller, so the two event sets differ only in what is
# registered where. Exits silently unless running inside a session maestro
# spawned (#103 env).
# Event -> ActivityState mapping:
# PreToolUse -> Active (tool started; agent is working)
# PostToolUse -> Active (tool finished)
# Notification -> AwaitingInput (permission / idle notice)
# Stop -> AwaitingInput (turn ended; waiting on the user)
# StopFailure -> Error (turn ended on an API error)
# Prompt -> the payload is forwarded to the prompt route (#131)
# PreToolUse -> Active (tool started; agent is working)
# PostToolUse -> Active (tool finished)
# Notification -> AwaitingInput (claude code: permission / idle)
# PermissionRequest -> AwaitingInput (codex: waiting on an approval)
# Stop -> AwaitingInput (turn ended; waiting on the user)
# StopFailure -> Error (claude code: turn ended on an error)
# Prompt -> the payload is forwarded to the prompt route (#131)
state="$1"
[ -n "$MAESTRO_SESSION_ID" ] || exit 0
[ -n "$MAESTRO_URL" ] || exit 0
@@ -84,23 +165,15 @@ if [ "$state" = "Prompt" ]; then
>/dev/null 2>&1 || true
exit 0
fi
fields="\"state\":\"$state\""
body="{\"state\":\"$state\"}"
# StopFailure payloads carry the outcome in "error". Only a plain enum
# spelling is reported as the detail — anything else (quotes, backslashes,
# prose) would risk breaking the JSON body, and the spelling already names
# the failure.
detail=$(printf '%s' "$payload" | sed -n 's/.*"error"[[:space:]]*:[[:space:]]*"\([a-z_]*\)".*/\1/p')
if [ -n "$detail" ]; then
fields="$fields,\"detail\":\"$detail\""
body="{\"state\":\"$state\",\"detail\":\"$detail\"}"
fi
# Every Claude Code hook payload carries its own conversation id (#161).
# Constrained to a safe charset for the same reason as "detail" above: it is
# spliced into the JSON body unescaped.
session_id=$(printf '%s' "$payload" | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([A-Za-z0-9_-]*\)".*/\1/p')
if [ -n "$session_id" ]; then
fields="$fields,\"session_id\":\"$session_id\""
fi
body="{$fields}"
curl -fsS --connect-timeout 1 --max-time 2 \
-H 'Content-Type: application/json' \
-d "$body" \
@@ -123,34 +196,29 @@ pub fn script_path(home: &Path) -> std::path::PathBuf {
home.join(".local/share/maestro").join(SCRIPT_FILE_NAME)
}
/// The user-level Claude Code settings file for a given home directory.
pub fn settings_path(home: &Path) -> std::path::PathBuf {
home.join(".claude/settings.json")
}
/// Whether `settings_path` already carries a maestro hook (#106). Presence
/// only: an install from an older release answers `true` even when it is
/// missing events this build ships — see [`is_current`].
/// Whether the file at `settings_path` already carries a maestro hook
/// (#106). Presence only: an install from an older release answers `true`
/// even when it is missing events this build ships — see [`is_current`].
pub fn is_installed(settings_path: &Path) -> bool {
settings_json(settings_path)
.map(|root| find_maestro_commands(&root).next().is_some())
.unwrap_or(false)
}
/// Whether what is on disk is what this build ships: the script at
/// `script_path` byte-for-byte, and exactly one settings entry per event in
/// [`HOOK_EVENTS`] and [`PROMPT_EVENTS`], pointing at that script.
/// Whether what is on disk is what this build ships for `target`: the script
/// at `script_path` byte-for-byte, and exactly one settings entry per one of
/// the target's [`Target::events`], pointing at that script.
///
/// This is the upgrade question. A maestro that gains an event — #131 added
/// `UserPromptSubmit` long after #106 had installed itself — leaves every
/// existing user with a present but incomplete install, which
/// [`is_installed`] cannot tell apart from a current one.
pub fn is_current(settings_path: &Path, script_path: &Path) -> bool {
/// This is the upgrade question, asked per [`Target`]. A maestro that gains
/// an event — #131 added `UserPromptSubmit` long after #106 had installed
/// itself — leaves every existing user with a present but incomplete
/// install, which [`is_installed`] cannot tell apart from a current one.
pub fn is_current(target: Target, settings_path: &Path, script_path: &Path) -> bool {
if std::fs::read_to_string(script_path).ok().as_deref() != Some(HOOK_SCRIPT) {
return false;
}
settings_json(settings_path)
.map(|root| maestro_entries(&root) == expected_entries(script_path))
.map(|root| maestro_entries(&root) == expected_entries(target, script_path))
.unwrap_or(false)
}
@@ -181,14 +249,13 @@ fn maestro_entries(root: &serde_json::Value) -> Vec<(String, String)> {
found
}
/// What [`install`] writes for `script_path`, in the same shape.
fn expected_entries(script_path: &Path) -> Vec<(String, String)> {
let mut want: Vec<(String, String)> = HOOK_EVENTS
.iter()
.chain(PROMPT_EVENTS.iter())
/// What [`install`] writes for `target` and `script_path`, in the same shape.
fn expected_entries(target: Target, script_path: &Path) -> Vec<(String, String)> {
let mut want: Vec<(String, String)> = target
.events()
.map(|(event, state)| {
(
(*event).to_string(),
event.to_string(),
format!("{} {state}", script_path.display()),
)
})
@@ -215,28 +282,40 @@ pub fn write_script(path: &Path) -> Result<(), HooksError> {
Ok(())
}
/// Install the full hook setup for `home`: write the script under
/// `.local/share/maestro/` and register it in `.claude/settings.json`.
/// Install the full hook setup for one target under `home`: write the script
/// under `.local/share/maestro/` and register it in the target's hook file.
/// Idempotent; returns whether anything changed on disk.
pub fn install_for_home(home: &Path) -> Result<bool, HooksError> {
pub fn install_target(target: Target, home: &Path) -> Result<bool, HooksError> {
let script = script_path(home);
let settings = settings_path(home);
let settings = target.settings_path(home);
// A script left over from an older release counts as a change even when
// the settings entries pointing at it are already right.
let changed = !is_current(&settings, &script);
let changed = !is_current(target, &settings, &script);
write_script(&script)?;
install(&settings, &script)?;
install(target, &settings, &script)?;
Ok(changed)
}
/// Register the hook command for every event in [`HOOK_EVENTS`] in the
/// settings file at `settings_path`. Idempotent: existing maestro entries are
/// updated in place wherever they sit, stale ones removed, duplicates folded;
/// every non-maestro entry and every unrelated top-level field survives.
/// Install for every [`Target::ALL`], reporting each one separately. One
/// target failing — an unwritable file, unparseable JSON — never stops the
/// others: an agent whose hook could not be written is one agent without
/// activity reporting, not a failed install.
pub fn install_for_home(home: &Path) -> Vec<(Target, Result<bool, HooksError>)> {
Target::ALL
.iter()
.map(|&target| (target, install_target(target, home)))
.collect()
}
/// Register the hook command for every one of `target`'s [`Target::events`]
/// in the settings file at `settings_path`. Idempotent: existing maestro
/// entries are updated in place wherever they sit, stale ones removed,
/// duplicates folded; every non-maestro entry and every unrelated top-level
/// field survives.
///
/// Never inspects or writes anywhere but `settings_path` — callers pass the
/// user's real home or a temp one in tests, never a repository.
pub fn install(settings_path: &Path, script_path: &Path) -> Result<(), HooksError> {
pub fn install(target: Target, settings_path: &Path, script_path: &Path) -> Result<(), HooksError> {
let mut root = settings_json(settings_path)?;
// Strip every maestro entry wherever it currently sits, so an older
@@ -244,7 +323,7 @@ pub fn install(settings_path: &Path, script_path: &Path) -> Result<(), HooksErro
strip_maestro_entries(&mut root);
let hooks = ensure_object(&mut root, "hooks");
for (event, state) in HOOK_EVENTS.iter().chain(PROMPT_EVENTS.iter()).copied() {
for (event, state) in target.events() {
let groups = ensure_array(hooks, event);
let mut group = serde_json::Map::new();
group.insert(
+136 -67
View File
@@ -1,15 +1,24 @@
//! Tests for the shipped hook script (run as a real `/bin/sh` subprocess)
//! and the settings-file installer. Everything happens under a temp `$HOME`;
//! the developer's own `~/.claude/settings.json` is never touched.
//! the developer's own `~/.claude/settings.json` and `~/.codex/hooks.json`
//! are never touched.
use std::io::{Read, Write};
use std::process::{Command, Stdio};
use super::*;
/// How many commands one install writes: every activity event, plus the
/// prompt event when `haiku-autoname` is on (#131).
const INSTALLED_EVENTS: usize = HOOK_EVENTS.len() + PROMPT_EVENTS.len();
/// How many commands one install writes for a target: its activity events,
/// plus the prompt event when `haiku-autoname` is on (#131).
fn installed_events(target: Target) -> usize {
target.events().count()
}
/// The Claude Code hook file under a temp home. Most installer tests only
/// need one target, and this is the one they were written against.
fn settings_path(home: &Path) -> std::path::PathBuf {
Target::ClaudeCode.settings_path(home)
}
fn sh(script: &str, args: &[&str], envs: &[(&str, &str)], payload: &[u8]) -> std::process::Output {
let mut cmd = Command::new("/bin/sh");
@@ -70,7 +79,7 @@ fn owned_envs<'a>(envs: &'a [(&'static str, String)]) -> Vec<(&'static str, &'a
/// exit 0, no output, stdin drained.
#[test]
fn script_exits_silently_without_maestro_env() {
for (event, _) in HOOK_EVENTS {
for (event, _) in CLAUDE_EVENTS {
let out = sh(HOOK_SCRIPT, &[event], &[], br#"{"session_id":"x"}"#);
assert!(out.status.success(), "{event:?}: {:?}", out.stderr);
assert!(out.stdout.is_empty(), "{event:?}: {:?}", out.stdout);
@@ -103,7 +112,7 @@ fn script_posts_mapped_state_to_the_activity_endpoint() {
/// Every event maps to the state the contract specifies.
#[test]
fn event_state_mapping_matches_the_contract() {
let map: std::collections::HashMap<&str, &str> = HOOK_EVENTS.into_iter().collect();
let map: std::collections::HashMap<&str, &str> = CLAUDE_EVENTS.into_iter().collect();
assert_eq!(map["PreToolUse"], "Active");
assert_eq!(map["PostToolUse"], "Active");
assert_eq!(map["Notification"], "AwaitingInput");
@@ -128,41 +137,6 @@ fn script_reports_the_error_spelling_as_detail() {
assert!(request.contains(r#""detail":"rate_limit""#), "{request}");
}
/// #161: the payload's own `session_id` — the agent's conversation id — is
/// parsed out and forwarded in the activity body, so the daemon can persist
/// it on the session.
#[test]
fn script_forwards_the_payloads_session_id_in_the_activity_body() {
let request = capture_request(|url| {
sh(
HOOK_SCRIPT,
&["Active"],
&owned_envs(&session_envs(&url)),
br#"{"session_id":"conv-abc123","hook_event_name":"PreToolUse"}"#,
)
});
assert!(request.contains(r#""state":"Active""#), "{request}");
assert!(
request.contains(r#""session_id":"conv-abc123""#),
"{request}"
);
}
/// A payload without a `session_id` (or one with an unexpected shape) must
/// never produce a broken body — no `session_id` field is posted.
#[test]
fn script_posts_no_session_id_when_the_payload_has_none() {
let request = capture_request(|url| {
sh(
HOOK_SCRIPT,
&["AwaitingInput"],
&owned_envs(&session_envs(&url)),
br#"{"hook_event_name":"Stop","stop_hook_active":false}"#,
)
});
assert!(!request.contains("session_id"), "{request}");
}
/// An `error` value that is not a plain enum spelling (or a payload without
/// one at all) must never produce a broken body — no detail is posted.
#[test]
@@ -238,10 +212,10 @@ fn fresh_install_registers_every_event() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
write_script(&script_path(home)).unwrap();
install(&settings_path(home), &script_path(home)).unwrap();
install(Target::ClaudeCode, &settings_path(home), &script_path(home)).unwrap();
let cmds = installed_commands(&settings_path(home));
assert_eq!(cmds.len(), INSTALLED_EVENTS);
assert_eq!(cmds.len(), installed_events(Target::ClaudeCode));
let joined = cmds.join("\n");
assert_eq!(joined.matches("Active").count(), 2);
assert!(joined.contains("Error"));
@@ -256,15 +230,15 @@ fn fresh_install_registers_every_event() {
fn install_is_idempotent() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
install(&settings_path(home), &script_path(home)).unwrap();
install(Target::ClaudeCode, &settings_path(home), &script_path(home)).unwrap();
let once = std::fs::read_to_string(settings_path(home)).unwrap();
install(&settings_path(home), &script_path(home)).unwrap();
install(Target::ClaudeCode, &settings_path(home), &script_path(home)).unwrap();
let twice = std::fs::read_to_string(settings_path(home)).unwrap();
assert_eq!(once, twice);
assert_eq!(
installed_commands(&settings_path(home)).len(),
INSTALLED_EVENTS
installed_events(Target::ClaudeCode)
);
}
@@ -292,12 +266,12 @@ fn install_updates_an_older_maestro_hook_in_place() {
.path()
.join(".local/share/maestro")
.join(SCRIPT_FILE_NAME);
install(&settings, &new_script).unwrap();
install(Target::ClaudeCode, &settings, &new_script).unwrap();
let cmds = installed_commands(&settings);
assert_eq!(
cmds.len(),
INSTALLED_EVENTS,
installed_events(Target::ClaudeCode),
"old entry replaced, not kept: {cmds:?}"
);
assert!(!cmds.iter().any(|c| c == old_script));
@@ -333,7 +307,7 @@ fn install_preserves_foreign_hooks_and_removes_stale_maestro_ones() {
.path()
.join(".local/share/maestro")
.join(SCRIPT_FILE_NAME);
install(&settings, &new_script).unwrap();
install(Target::ClaudeCode, &settings, &new_script).unwrap();
let root: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
@@ -346,7 +320,7 @@ fn install_preserves_foreign_hooks_and_removes_stale_maestro_ones() {
let cmds = installed_commands(&settings);
assert_eq!(
cmds.iter().filter(|c| c.contains(HOOK_MARKER)).count(),
INSTALLED_EVENTS,
installed_events(Target::ClaudeCode),
"{cmds:?}"
);
}
@@ -356,9 +330,15 @@ fn install_preserves_foreign_hooks_and_removes_stale_maestro_ones() {
fn is_installed_tracks_reality() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
assert!(!is_installed(&settings_path(home)));
install_for_home(home).unwrap();
assert!(is_installed(&settings_path(home)));
for target in Target::ALL {
assert!(!is_installed(&target.settings_path(home)));
}
for (_, result) in install_for_home(home) {
result.unwrap();
}
for target in Target::ALL {
assert!(is_installed(&target.settings_path(home)));
}
}
/// The full home-level install leaves the script executable.
@@ -366,7 +346,9 @@ fn is_installed_tracks_reality() {
fn install_for_home_writes_an_executable_script() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
install_for_home(home).unwrap();
for (_, result) in install_for_home(home) {
result.unwrap();
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
@@ -385,7 +367,7 @@ fn install_for_home_writes_an_executable_script() {
fn install_registers_the_prompt_event() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
install(&settings_path(home), &script_path(home)).unwrap();
install(Target::ClaudeCode, &settings_path(home), &script_path(home)).unwrap();
let root: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(settings_path(home)).unwrap()).unwrap();
@@ -402,7 +384,7 @@ fn install_registers_the_prompt_event() {
fn install_registers_no_prompt_event_without_the_feature() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
install(&settings_path(home), &script_path(home)).unwrap();
install(Target::ClaudeCode, &settings_path(home), &script_path(home)).unwrap();
let root: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(settings_path(home)).unwrap()).unwrap();
@@ -471,8 +453,12 @@ fn the_script_posts_a_prompt_payload_unchanged() {
fn is_current_rejects_an_install_missing_an_event() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
install_for_home(home).unwrap();
assert!(is_current(&settings_path(home), &script_path(home)));
install_target(Target::ClaudeCode, home).unwrap();
assert!(is_current(
Target::ClaudeCode,
&settings_path(home),
&script_path(home)
));
// Drop one event, as an older install would have left the file.
let mut root: serde_json::Value =
@@ -485,7 +471,11 @@ fn is_current_rejects_an_install_missing_an_event() {
.unwrap();
assert!(is_installed(&settings_path(home)));
assert!(!is_current(&settings_path(home), &script_path(home)));
assert!(!is_current(
Target::ClaudeCode,
&settings_path(home),
&script_path(home)
));
}
/// A settings file that is complete but points at a script from an older
@@ -494,9 +484,13 @@ fn is_current_rejects_an_install_missing_an_event() {
fn is_current_rejects_a_stale_script() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
install_for_home(home).unwrap();
install_target(Target::ClaudeCode, home).unwrap();
std::fs::write(script_path(home), "#!/bin/sh\nexit 0\n").unwrap();
assert!(!is_current(&settings_path(home), &script_path(home)));
assert!(!is_current(
Target::ClaudeCode,
&settings_path(home),
&script_path(home)
));
}
/// Nothing installed at all is not current.
@@ -504,18 +498,93 @@ fn is_current_rejects_a_stale_script() {
fn is_current_rejects_a_missing_install() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
assert!(!is_current(&settings_path(home), &script_path(home)));
assert!(!is_current(
Target::ClaudeCode,
&settings_path(home),
&script_path(home)
));
}
/// `install_for_home` reports a stale script as a change, even when the
/// `install_target` reports a stale script as a change, even when the
/// settings entries pointing at it already read correctly.
#[test]
fn install_for_home_reports_a_stale_script_as_a_change() {
fn install_target_reports_a_stale_script_as_a_change() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
assert!(install_for_home(home).unwrap());
assert!(!install_for_home(home).unwrap());
assert!(install_target(Target::ClaudeCode, home).unwrap());
assert!(!install_target(Target::ClaudeCode, home).unwrap());
std::fs::write(script_path(home), "#!/bin/sh\nexit 0\n").unwrap();
assert!(install_for_home(home).unwrap());
assert!(install_target(Target::ClaudeCode, home).unwrap());
}
/// Codex is hooked in its own file, with its own event set: the two agents
/// share the script but not the events, so neither file carries the other's
/// spellings (#131 follow-up — a codex session was never named because
/// nothing installed its `UserPromptSubmit` hook).
#[test]
fn each_target_gets_its_own_file_and_events() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
for (_, result) in install_for_home(home) {
result.unwrap();
}
let claude = events_in(&Target::ClaudeCode.settings_path(home));
let codex = events_in(&Target::Codex.settings_path(home));
assert_eq!(
Target::Codex.settings_path(home),
home.join(".codex/hooks.json")
);
// Claude Code's failure signal has no codex equivalent, and codex's
// approval notice is spelled differently.
assert!(claude.contains(&"StopFailure".to_string()));
assert!(claude.contains(&"Notification".to_string()));
assert!(!codex.contains(&"StopFailure".to_string()));
assert!(!codex.contains(&"Notification".to_string()));
assert!(codex.contains(&"PermissionRequest".to_string()));
// Both report work and both report a finished turn.
for event in ["PreToolUse", "PostToolUse", "Stop"] {
assert!(claude.contains(&event.to_string()), "claude {event}");
assert!(codex.contains(&event.to_string()), "codex {event}");
}
assert_eq!(
installed_commands(&Target::Codex.settings_path(home)).len(),
installed_events(Target::Codex)
);
}
/// #131: a codex session names itself the same way a Claude Code one does —
/// same event spelling, same script, same `Prompt` argument.
#[cfg(feature = "haiku-autoname")]
#[test]
fn codex_reports_its_first_prompt_too() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
install_target(Target::Codex, home).unwrap();
let root: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(Target::Codex.settings_path(home)).unwrap())
.unwrap();
let cmd = root["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"]
.as_str()
.unwrap()
.to_string();
assert!(cmd.ends_with(" Prompt"), "{cmd}");
assert!(cmd.contains(SCRIPT_FILE_NAME));
}
/// Every event name a settings file registers a maestro hook under.
fn events_in(settings: &Path) -> Vec<String> {
serde_json::from_str::<serde_json::Value>(&std::fs::read_to_string(settings).unwrap())
.unwrap()
.get("hooks")
.and_then(|h| h.as_object())
.into_iter()
.flatten()
.map(|(event, _)| event.clone())
.collect()
}
-1
View File
@@ -284,7 +284,6 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
agent_session_id: None,
}
}
+1 -137
View File
@@ -116,13 +116,6 @@ pub struct Session {
/// sorts a never-focused session after every session that has one.
#[serde(default)]
pub last_focused_at: Option<DateTime<Utc>>,
/// The conversation id the agent running in this session reported for
/// itself (#161), parsed from a hook's `session_id` field. Written once,
/// from the first activity report that carries one, and left alone after
/// — an agent's own session id does not change mid-session. `None` for
/// sessions that never run an agent (plain shells) or predate the field.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_session_id: Option<String>,
}
/// Default for [`Session::last_activity_at`] when an older payload omits it.
@@ -130,38 +123,10 @@ fn epoch() -> DateTime<Utc> {
DateTime::from_timestamp(0, 0).expect("unix epoch is a valid timestamp")
}
/// 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)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SessionStatus {
Running,
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
@@ -219,11 +184,6 @@ pub struct ReportActivityRequest {
/// The daemon caps its length server-side.
#[serde(default)]
pub detail: Option<String>,
/// The agent's own conversation id (#161), forwarded from the hook
/// payload's `session_id` field. `None` for hook events that don't carry
/// one, or hooks predating this field.
#[serde(default)]
pub session_id: Option<String>,
}
/// Reject an unknown [`ActivityState`] spelling instead of degrading it to
@@ -529,34 +489,8 @@ impl ApiError {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpawnPreset {
pub id: String,
#[serde(default)]
pub label: String,
pub command: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resume_command: Option<String>,
}
/// Placeholder a preset's `resume_command` may carry for the agent's own
/// conversation id (#165), substituted from [`Session::agent_session_id`].
pub const AGENT_SESSION_PLACEHOLDER: &str = "{agent_session}";
impl SpawnPreset {
/// The command that resumes a session spawned from this preset, with
/// [`AGENT_SESSION_PLACEHOLDER`] filled in from `agent_session`.
///
/// `None` when the preset has no `resume_command` — a plain shell has
/// nothing to resume — and also when the template asks for an agent
/// session id the session never reported: `claude --resume` with an empty
/// id resumes nothing, so the caller is better off respawning the original
/// command than running a broken one.
pub fn resume_command_for(&self, agent_session: Option<&str>) -> Option<String> {
let template = self.resume_command.as_deref()?;
if !template.contains(AGENT_SESSION_PLACEHOLDER) {
return Some(template.to_string());
}
let id = agent_session.filter(|s| !s.is_empty())?;
Some(template.replace(AGENT_SESSION_PLACEHOLDER, id))
}
}
/// Build provenance of the running daemon: the commit it was compiled
@@ -808,47 +742,6 @@ pub enum ClientControl {
mod tests {
use super::*;
fn preset(resume: Option<&str>) -> SpawnPreset {
SpawnPreset {
id: "claude".into(),
label: "Claude".into(),
command: "claude".into(),
resume_command: resume.map(str::to_string),
}
}
#[test]
fn resume_command_substitutes_the_agent_session() {
let p = preset(Some("claude --resume {agent_session}"));
assert_eq!(
p.resume_command_for(Some("abc-123")).as_deref(),
Some("claude --resume abc-123")
);
}
#[test]
fn resume_command_without_a_placeholder_ignores_the_agent_session() {
let p = preset(Some("opencode --continue"));
assert_eq!(
p.resume_command_for(None).as_deref(),
Some("opencode --continue")
);
}
/// A template that wants an id the session never reported would run
/// `claude --resume` against nothing; the caller respawns instead.
#[test]
fn resume_command_is_none_without_the_id_it_asks_for() {
let p = preset(Some("claude --resume {agent_session}"));
assert!(p.resume_command_for(None).is_none());
assert!(p.resume_command_for(Some("")).is_none());
}
#[test]
fn a_preset_without_a_resume_command_has_none() {
assert!(preset(None).resume_command_for(Some("abc")).is_none());
}
#[test]
fn version_compatibility_is_major_only() {
assert!(is_compatible(PROTOCOL_VERSION));
@@ -969,7 +862,6 @@ mod tests {
current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")),
blocked_work: None,
last_focused_at: None,
agent_session_id: None,
}
}
@@ -1139,7 +1031,6 @@ mod tests {
let full = ReportActivityRequest {
state: ActivityState::AwaitingInput,
detail: Some("waiting on permission".into()),
session_id: Some("conv-abc123".into()),
};
assert_eq!(full, round_trip(&full));
@@ -1168,33 +1059,6 @@ mod tests {
serde_json::to_value(SessionStatus::Dead).unwrap(),
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]
-15
View File
@@ -66,12 +66,6 @@ pub fn session_focus(id: Uuid) -> String {
format!("{SESSIONS}/{id}/focus")
}
/// `POST /api/sessions/:id/resume` (#166): respawn a dormant session under its
/// own id, keeping its sidecar, name, worktree and `created_at`.
pub fn session_resume(id: Uuid) -> String {
format!("{SESSIONS}/{id}/resume")
}
/// `WS /api/attach/:id` — raw PTY bytes.
pub fn attach(id: Uuid) -> String {
format!("{API_PREFIX}/attach/{id}")
@@ -85,7 +79,6 @@ pub mod patterns {
pub const SESSION_INPUT: &str = "/api/sessions/:id/input";
pub const SESSION_PROMPT: &str = "/api/sessions/:id/prompt";
pub const SESSION_FOCUS: &str = "/api/sessions/:id/focus";
pub const SESSION_RESUME: &str = "/api/sessions/:id/resume";
pub const ATTACH: &str = "/api/attach/:id";
}
@@ -209,10 +202,6 @@ mod tests {
session_focus(id()),
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/focus"
);
assert_eq!(
session_resume(id()),
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/resume"
);
assert_eq!(
attach(id()),
"/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
@@ -247,10 +236,6 @@ mod tests {
patterns::SESSION_FOCUS.trim_end_matches(":id/focus"),
session(id()).trim_end_matches(&id().to_string())
);
assert_eq!(
patterns::SESSION_RESUME.trim_end_matches(":id/resume"),
session(id()).trim_end_matches(&id().to_string())
);
assert_eq!(
patterns::ATTACH.trim_end_matches(":id"),
attach(id()).trim_end_matches(&id().to_string())
-1
View File
@@ -47,7 +47,6 @@ pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
agent_session_id: None,
}
}
-6
View File
@@ -1898,7 +1898,6 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
agent_session_id: None,
})
.collect(),
});
@@ -2080,7 +2079,6 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
agent_session_id: None,
}],
});
app.dispatch(Action::SidebarNext);
@@ -2108,7 +2106,6 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
agent_session_id: None,
}
}
@@ -3217,7 +3214,6 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: focused,
agent_session_id: None,
};
let older = Utc::now() - chrono::Duration::seconds(60);
app.catalog.apply_event(CatalogEvent::Snapshot {
@@ -3496,7 +3492,6 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
agent_session_id: None,
}],
});
app.dispatch(Action::KillHighlighted);
@@ -3532,7 +3527,6 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
agent_session_id: None,
}],
});
}
-1
View File
@@ -223,7 +223,6 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
agent_session_id: None,
}
}
+2 -22
View File
@@ -194,9 +194,7 @@ pub enum BarOutcome {
pub fn order_sessions_for_bar(sessions: &[Session]) -> Vec<&Session> {
let mut ordered: Vec<&Session> = sessions.iter().collect();
ordered.sort_by(|a, b| {
// 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);
let dead = |s: &Session| u8::from(s.status == SessionStatus::Dead);
dead(a)
.cmp(&dead(b))
.then_with(|| {
@@ -321,7 +319,7 @@ impl CommandBar {
.map(|s| BarEntry {
candidate: MatchCandidate::Session(session_candidate(s, home_dir)),
target: BarTarget::Session(s.id),
dead: s.status != SessionStatus::Running,
dead: s.status == SessionStatus::Dead,
chords: Vec::new(),
})
.collect();
@@ -811,7 +809,6 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
agent_session_id: None,
}
}
@@ -892,23 +889,6 @@ mod tests {
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]
fn typing_ranks_through_the_matcher_and_returns_ranges() {
let mut bar = CommandBar::new(
-4
View File
@@ -1705,7 +1705,6 @@ mod tests {
id: "aider".into(),
label: "Aider".into(),
command: "aider".into(),
resume_command: None,
}]);
assert_eq!(
draft.command(),
@@ -1728,13 +1727,11 @@ mod tests {
id: "claude".into(),
label: "Claude".into(),
command: "claude".into(),
resume_command: None,
},
SpawnPreset {
id: "aider".into(),
label: "Aider".into(),
command: "aider".into(),
resume_command: None,
},
]);
assert_eq!(draft.command(), "aider");
@@ -1754,7 +1751,6 @@ mod tests {
id: "claude".into(),
label: "Claude".into(),
command: "claude".into(),
resume_command: None,
}]);
assert!(draft.is_custom());
assert_eq!(draft.command(), "bash -l");
-1
View File
@@ -183,7 +183,6 @@ mod tests {
id: id.into(),
label: id.into(),
command: command.into(),
resume_command: None,
}
}
-41
View File
@@ -268,8 +268,6 @@ impl Sidebar {
fn name_row(&self, s: &Session, width: usize, focused: bool) -> Vec<Span<'static>> {
let glyph = match (s.status, self.attached.contains(&s.id)) {
(SessionStatus::Dead, _) => "✗",
// #162: known from its sidecar, nothing running behind it.
(SessionStatus::Dormant, _) => "◌",
(SessionStatus::Running, true) => "●",
(SessionStatus::Running, false) => "○",
};
@@ -324,7 +322,6 @@ impl Sidebar {
// on line 1; the kill confirm names the specifics.
let blocked = s.blocked_work.filter(|b| !b.is_clear());
let dead = s.status == SessionStatus::Dead;
let dormant = s.status == SessionStatus::Dormant;
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
let fill = self.card_fill(s, focused);
let base = card_base(fill, focused);
@@ -364,9 +361,6 @@ impl Sidebar {
if dead {
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.
let command = Span::styled(format!(" {}", command_program(&s.command)), muted);
@@ -506,7 +500,6 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
agent_session_id: None,
}
}
@@ -1075,39 +1068,6 @@ 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.
#[test]
fn meta_line_drops_the_commands_arguments() {
@@ -1306,7 +1266,6 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
agent_session_id: None,
};
let sb = Sidebar {
sessions: vec![s],
+3 -3
View File
@@ -123,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.
**A session that has never been focused sorts after every session that has**,
and among those, most recently created first. Sessions with no live pane —
`Dead`, and `Dormant` (#162) — sort after live ones regardless of recency: a
session you cannot attach to is not where you just were.
and among those, most recently created first. Dead sessions sort after live
ones regardless of recency: a session you cannot attach to is not where you
just were.
Under `>` an empty query lists every command in registry order. Under `~` it
lists recent dirs, most recent first, then the project list.
-20
View File
@@ -243,26 +243,6 @@ Pick `/tmp` if the daemon must outlive your login session and you can't run
tmux sockets with it). The env var `MAESTRO_DAEMON_TMUX_TMPDIR` takes
precedence over the file.
## What happens to sessions after a reboot
A host reboot kills the tmux server, so every session the daemon knew is dead
while its record on disk survives. What recovery does with those is
`[daemon].resume_on_boot`:
```toml
[daemon]
resume_on_boot = "dormant" # "dormant" (default) | "auto" | "never"
```
- `dormant` — the sessions are listed but not running, and you resume the ones
you want from the web UI or the TUI.
- `auto` — the daemon resumes them all itself during startup. Only pick this
on a host where restarting every agent unattended is safe: they come back on
the flags they were spawned with, with nobody watching.
- `never` — the records are deleted and a reboot is a clean slate.
See `docs/RESTART.md` for the full contract.
## What the systemd unit looks like
The wizard writes a *user* unit (`systemctl --user`), since `maestro daemon`
-6
View File
@@ -28,12 +28,6 @@ A preview is a view, not a state. Nothing about it is durable:
| Already in a pane, in another tab | That tab is brought forward. The active tab is restored when the preview ends. |
| Not in any pane | A throwaway tab carries it. It is removed when the preview ends. |
A session that is not `Running` — a dormant one, most often — never gets a
live attach here or anywhere else a pane is filled: the daemon has nothing to
answer a `409 not_attachable` and the socket would just die (WS close 1006).
Its pane shows the same metadata-and-resume view `docs/SIDEBAR.md` describes
for its card, honest about the fact that there is nothing to preview yet.
The throwaway tab never counts as an open session: the tab bar does not appear
because of it, the mobile shell does not switch away from the session list for
it, and the sidebar does not mark the session as open.
-110
View File
@@ -68,116 +68,6 @@ 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
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 always asks
tmux — `tmux list-sessions` is the single source of truth for liveness, and the
kernel's `boot_id` only tells a reboot from a restart in the log (#164, #174).
A boot id changes without the tmux server dying whenever the state directory is
restored, a VM or container snapshot is rolled back, or the `boot_id` file is
truncated; a session tmux still holds is adopted `Running` in every one of
those cases. 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.
A dormant session is brought back with `POST /api/sessions/:id/resume` (#166),
which respawns tmux under the **same** uuid and sidecar — `metadata.name`,
`created_at`, `env` and worktree ownership all survive, and only the tmux
session and the PTY bridge are new. The command is the matching spawn preset's
`resume_command` with `{agent_session}` filled in from the session's own
`agent_session_id`, so an agent comes back inside its old conversation; a
session with no matching preset, no `resume_command`, or no reported
conversation id is respawned on the command it was created with. `Session::command`
always keeps that original command, so a second resume resolves the same preset.
Resume is refused with `409` for a session that is already running
(`already_running`) and for one whose `cwd` has disappeared (`cwd_missing`) —
neither is something a respawn could fix. A session with a resume already in
flight is refused as `already_running` too (#175): the respawn happens with
the session map unlocked, so the record is marked in-flight before it starts
and the second request is answered on that state rather than on its tmux spawn
colliding with the first one's. A resume that loses a race with a kill takes
its own sidecar back off disk, so the killed session does not return as a
dormant ghost on the next recovery. The other thing to do with a dormant
session is dismiss it, which removes the sidecar so the next recovery does not
bring it back.
## What the pane looks like when it comes back
The PTY replay ring lives in memory, so a resumed session would otherwise open
on a blank pane: the conversation is back, the terminal history the operator
was reading is not. Each bridge's ring is mirrored to
`<state_dir>/scrollback/<id>.bin` (#171) and handed to the new bridge on
resume, and on the recovery pass that re-adopts a session tmux still holds —
that one lost its ring with the previous daemon too. The restored bytes go out
ahead of the live ones, and are dropped once the ring holds a full budget of
new output of its own, which is the point where everything restored is older
than the budget covers.
The file is capped at the same `[daemon] pty_replay_bytes` budget the ring
uses — one budget, not two — and it is deleted when the session is killed, and
when recovery prunes its sidecar. Nothing here can fail a resume: a missing,
unreadable or oversized file degrades to an empty pane, which is exactly what
the operator had before it existed.
It is flushed every 10 seconds by the liveness watcher, and once more when a
session's bridge is sealed on exit. The timer is the part that matters: a host
reboot runs no exit path at all, so scrollback written only on a clean shutdown
would be missing in the one case this exists for. Ten seconds bounds the loss
to the last few lines — scrollback is visual continuity, not state — and costs
at most one file write per *active* session per tick, since a session whose
bridge has read nothing since the last flush is skipped.
## What recovery does with them: `resume_on_boot`
Whether a recovered-but-dead session is left dormant at all is the operator's
call, per host, via `[daemon].resume_on_boot` (#167):
| value | what recovery does |
| ----------- | --------------------------------------------------------------------- |
| `"dormant"` | **Default.** Register each one dormant and wait for the operator. |
| `"auto"` | Resume each one during recovery, itself. |
| `"never"` | Prune the sidecars instead — a reboot is a clean slate. |
`dormant` is the default because resuming agents unattended is a foot-gun:
they come back on whatever flags they were spawned with, seconds after a
reboot, with nobody watching. `auto` exists for hosts where that is known to
be safe, and it is opted into rather than inherited.
`auto` calls the same `resume` the client's `POST /api/sessions/:id/resume`
does — same uuid, same sidecar, the preset's `resume_command` — so there is
one resume path and not two. A session that fails to resume is logged and
stays dormant; the rest still come back.
`never` prunes the same way #163 does for a missing `cwd`: the sidecar is
deleted and nothing is registered, so the session is not listed and the next
recovery does not find it. Whatever worktree it owned is left on disk.
The policy only decides the fate of sessions with no live pane. A session
tmux still holds is adopted `Running` by the first pass under every policy,
including `never`.
Recovery logs the policy that ran and the number of sessions it acted on, so
a host that resumes agents unattended says so in its own journal.
Attaching to a session that has no bridge answers `409 not_attachable`, not
`404 unknown_session`: the session exists, and a client that cannot tell "gone"
from "not running" cannot offer the resume.
`SessionStatus` is a wire type, so from this build forward it deserializes
leniently on both clients: a spelling the client does not know reads as `Dead`
rather than failing the whole session. That leniency lives in the client's own
binary, so it covers spellings added *after* this build, not this one — a CLI
or TUI still running an older build fails to parse a `Dormant` session, and
with it the whole session list, until it is upgraded. 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
- **Grace window** — 1200 ms between the socket dropping and the overlay
+12 -40
View File
@@ -56,7 +56,7 @@ itself is not.
Line 2 reads left to right:
```
[<duration>] [<detail>] <command> [<diff stat> | exited | dormant]
[<duration>] [<detail>] <command> [<diff stat> | exited]
```
Its minimum content is therefore just the command.
@@ -104,41 +104,8 @@ fragment; it no longer decides whether line 2 exists.
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
truncates with `…` ahead of the trailing fragments — a long detail never
pushes `<command>`, `<blocked work>`, `exited` or `dormant` off the line, and
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.
### Dormant cards (#168)
A dormant card is a muted variant of the normal card (the web client's
`is-dormant` class, the same muted foreground `exited` already uses): name,
cwd and last-known age render as usual, but the activity rail carries no
colour, since recovery resets a dormant session's activity to `Idle` and
there is nothing running to report otherwise.
The row's right-hand actions swap **spawn similar** for **resume** (↻) — there
is no live state to clone from — and call `POST /api/sessions/:id/resume`
(#166). **Kill** stays: it is how a dormant session is dismissed, since the
daemon already treats a kill of a dormant session as removing its sidecar
rather than anything it needs to be running to do.
A dormant session takes the **lowest precedence in the ordering**: within a
folder, every live (`Running` or `Dead`) session sorts before every dormant
one, name notwithstanding, and dormant sessions sort among themselves by name.
So a reboot that leaves a whole tree dormant never buries the live work a
session spawns into it.
Selecting a dormant session — by click, by keyboard, or by preview
(`docs/PREVIEW.md`) — never opens a live attach: the content pane shows the
same metadata and resume affordance the card does, rather than a terminal
that would die on the daemon's `409 not_attachable` (WS close 1006). Resuming
turns the session `Running`; the pane and the card pick that up on their own
next update, no navigation required.
pushes `<command>`, `<blocked work>`, or `exited` off the line, and neither
client ever lets it wrap.
`<diff stat>` is the blocked-work breakdown (#83) in diff notation, replacing
the spelled-out `⚠ 3 uncommitted files, 2 unpushed commits`. It renders only
@@ -242,7 +209,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
step up to.
The duration text inherits its state's colour; the exited, dormant and command
The duration text inherits its state's colour; the exited and command
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
take the `active` and `error` roles of the same surface.
@@ -283,6 +250,10 @@ Two rulings specific to a reported `Error` (#114):
process changes do not clear it — only the agent itself may withdraw what
the agent itself reported. Re-reporting `Error` extends the TTL rather than
stacking reports.
- **Only Claude Code reports `Error`.** Codex has no counterpart to
`StopFailure` in its hook event set, so a codex turn that ends on an API
error reports `AwaitingInput` and its card carries no error mark. A card
without the mark means "nothing reported a failure", not "nothing failed".
If a client receives an `ActivityState` variant it does not know, it must not
fail rendering: treat it as `Idle` (neutral/dim, line-2 rules apply as usual)
@@ -336,9 +307,10 @@ lands on the first row it was hiding. A highlight parked on a skipped row —
reachable by clicking a folder header — still steps to its neighbour rather
than jumping to the end of the list.
The web client needs a chord to reach the list at all (`Ctrl+Shift+H`, and the
same chord back): an attached terminal owns every plain keystroke. The TUI uses
`ctrl-w h`.
The web client needs a chord to reach the list at all (leader-`h` — `Ctrl+A h`
by default — and the same chord back): an attached terminal owns every plain
keystroke, and Escape is left to reach the pane's shell/app instead. The TUI
uses `ctrl-w h`.
Everything else is the same key on both clients:
+12 -4
View File
@@ -35,10 +35,18 @@ The branch name is used verbatim as a relative path, so `feature/login` becomes
back to a short hash of the branch.
If that path exists, the daemon appends `-2`, then `-3`, and so on until it finds
a free one. The suffix names the **directory only**. The branch is whatever was
requested; a second worktree of the same branch is not what happens here, because
git refuses to check the same branch out twice and the branch picker already
marks branches that are checked out elsewhere.
a free one.
For a *new* branch (`create_branch: true`), the suffix goes on the branch name
itself, and the directory follows since it is derived from the branch — a
random handle can otherwise reuse the name of a branch some earlier, already
removed worktree left behind, and `git worktree add -b` refuses outright even
though the directory is free.
For an *existing* branch (`create_branch: false`) the name must stay exact, so
only the directory is suffixed — a second worktree of the same branch is not
what happens here, because git refuses to check the same branch out twice and
the branch picker already marks branches that are checked out elsewhere.
### The `worktree_root` override
-12
View File
@@ -91,18 +91,6 @@ export async function killSession(id: Uuid, force = false): Promise<void> {
);
}
/** Respawn a dormant session under its own uuid and sidecar (#166/#168).
* Refused with `409` (`already_running` / `cwd_missing`) when there is
* nothing a respawn could fix. */
export async function resumeSession(id: Uuid): Promise<Session> {
return jsonOrError(
await fetch(routes.sessionResume(id), {
method: "POST",
credentials: "same-origin",
}),
);
}
/** Report whether this client's pane is the one currently showing `id` — not
* merely highlighted in a sidebar. The daemon uses this to clear an
* `AwaitingInput` glow once the pane has been looked at long enough
+10 -42
View File
@@ -4,8 +4,6 @@ import { Terminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css";
import { attachWsUrl } from "./routes.js";
import { FOCUS_SIDEBAR_EVENT } from "./catalog.js";
import { CommandSet, defineCommands } from "./commands.js";
import { escapeHtml } from "./html.js";
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js";
@@ -14,15 +12,6 @@ import { splitCommand, toolIconSvg } from "./toolIcon.js";
import type { ClientControl, Uuid } from "./types.js";
import { WebglPolicy } from "./webglRenderer.js";
// The pane's half of the command registry (#148): plain Escape hands the
// keyboard back to the session list, the pair to the list's Tab.
const paneKeys = new CommandSet(
defineCommands({
"pane.focusList": () => document.dispatchEvent(new CustomEvent(FOCUS_SIDEBAR_EVENT)),
}),
{ preventDefault: false },
);
/** One budget for the whole page — see webglRenderer.ts for why contexts are
* rationed rather than handed to every connection. */
const webglPolicy = new WebglPolicy();
@@ -65,17 +54,9 @@ export interface AttachOptions {
* dropped — but NOT on an intentional `dispose()` (eviction / user close). The
* shell uses this to auto-close the pane rather than leave a dead one. */
onClosed?: (sessionId: Uuid) => void;
/** Invoked when the handshake itself never completed — no retry was
* attempted (see `shouldReconnect`'s `everOpened`). Unlike `onClosed`, this
* is not "the session is gone": it is at least as likely a dormant
* session's `409 not_attachable` caught mid-status-update (#168 review).
* The shell re-renders rather than tearing the pane's leaf out, so a
* dormant session's resume affordance can take over in place. */
onNeverAttached?: (sessionId: Uuid) => void;
/** Asked on every close: should the socket be retried? The shell answers
* from the catalog — see `shouldRetryClose`. `everOpened` is false when
* this attempt never reached `open`. */
shouldReconnect?: (sessionId: Uuid, code: number, everOpened: boolean) => boolean;
* from the catalog — see `shouldRetryClose`. */
shouldReconnect?: (sessionId: Uuid, code: number) => boolean;
}
/** What the header shows about the session (name / command). Optional — falls
@@ -229,15 +210,6 @@ export function createAttachConnection(
}
}
// Plain Escape hands the keyboard back to the session list instead of the
// shell/app. `false` tells xterm to skip its own handling of this event —
// which is also why the set doesn't `preventDefault`: swallowing here is
// xterm's return value, not the browser's.
term.attachCustomKeyEventHandler((e) => {
if (e.type !== "keydown") return true;
return !paneKeys.handle(e);
});
// Touch scrolling: xterm's built-in touch handling is jittery (drags crawl a
// line at a time and stall), so map finger drags ourselves. Two routes:
// • normal buffer → term.scrollLines walks the scrollback.
@@ -387,10 +359,6 @@ export function createAttachConnection(
let ws: WebSocket;
let reconnectMs = INITIAL_RECONNECT_MS;
let reconnectTimer: number | null = null;
// #168 review: a fresh attempt that never reaches `open` is a rejected
// handshake (dormant/unknown session), not a dropped stream — see
// `shouldRetryClose`'s `everOpened` param.
let everOpened = false;
const sendControl = (msg: ClientControl) => {
if (ws.readyState !== WebSocket.OPEN) return;
@@ -414,7 +382,6 @@ export function createAttachConnection(
ws.binaryType = "arraybuffer";
ws.onopen = () => {
everOpened = true;
reconnectMs = INITIAL_RECONNECT_MS;
setState("connected", "open");
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
@@ -457,7 +424,7 @@ export function createAttachConnection(
// close handle their own pane teardown.
if (disposed) return;
if (opts.shouldReconnect?.(sessionId, ev.code, everOpened)) {
if (opts.shouldReconnect?.(sessionId, ev.code)) {
const delay = Math.min(reconnectMs, MAX_RECONNECT_MS);
setState("reconnecting…", "connecting");
reconnectTimer = window.setTimeout(() => {
@@ -468,12 +435,6 @@ export function createAttachConnection(
return;
}
if (!everOpened) {
setState("not attached", "closed");
opts.onNeverAttached?.(sessionId);
return;
}
setState(`closed (${ev.code})`, "closed");
opts.onClosed?.(sessionId);
};
@@ -489,6 +450,12 @@ export function createAttachConnection(
const resizeDisp = term.onResize(({ cols, rows }) => {
sendControl({ type: "Resize", cols, rows });
});
// xterm has no built-in copy-on-select; mirror the selection to the
// clipboard ourselves (best-effort — insecure contexts lack the API).
const selectionDisp = term.onSelectionChange(() => {
const text = term.getSelection();
if (text) void navigator.clipboard?.writeText(text).catch(() => {});
});
window.addEventListener("resize", onResize);
const visual = window.visualViewport ?? null;
@@ -544,6 +511,7 @@ export function createAttachConnection(
visual?.removeEventListener("resize", onResize);
dataDisp.dispose();
resizeDisp.dispose();
selectionDisp.dispose();
releaseWebgl();
try {
ws.close();
-51
View File
@@ -414,57 +414,6 @@ describe("session cards (#107)", () => {
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");
});
// #168: a dormant card is a muted variant with a resume action in place of
// "spawn similar", since there is nothing running to clone from live state.
it("renders a dormant card muted, with a resume action instead of spawn-similar", async () => {
const calls: string[] = [];
window.fetch = ((url: string, init?: RequestInit) => {
calls.push(`${init?.method ?? "GET"} ${url}`);
return Promise.resolve(
new Response(JSON.stringify({ id: "aaaaaaaa-0000-0000-0000-000000000120", status: "Running" }), {
status: 200,
}),
);
}) as typeof fetch;
const s = session({ id: "aaaaaaaa-0000-0000-0000-000000000120", status: "Dormant" });
const el = renderSidebar(storeWith([s])).el;
const row = rowFor(el, s.id) as HTMLElement;
expect(row.classList.contains("is-dormant")).toBe(true);
expect(row.querySelector(".leaf-clone")).toBeNull();
const resumeBtn = row.querySelector<HTMLButtonElement>(".leaf-resume");
expect(resumeBtn).toBeTruthy();
resumeBtn?.click();
await flush();
expect(calls).toContain(`POST /api/sessions/${s.id}/resume`);
});
// #168: a two-way running/dead test made a dormant card lie to screen
// readers — the visible flag says dormant, the aria-label said "exited".
it("marks a dormant row's status and aria-label distinctly from dead", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000119",
status: "Dormant",
});
const el = renderSidebar(storeWith([s])).el;
const row = rowFor(el, s.id) as HTMLElement;
expect(row.dataset.status).toBe("dormant");
expect(row.getAttribute("aria-label")).toContain("dormant");
expect(row.getAttribute("aria-label")).not.toContain("exited");
});
it("carries the state word only in tooltips and aria labels, never as text", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000019",
+19 -61
View File
@@ -1,5 +1,5 @@
import { escapeHtml } from "./html.js";
import { ApiRequestError, getDaemonInfo, killSession, renameSession, resumeSession } from "./api.js";
import { ApiRequestError, getDaemonInfo, killSession, renameSession } from "./api.js";
import { openForceKillDialog, openKillConfirmDialog } from "./killDialog.js";
import { openContextMenu } from "./contextMenu.js";
import { toggleHelp } from "./help.js";
@@ -53,10 +53,6 @@ export function requestOpenCommandBar(): void {
* connection pool. */
export const FOCUS_PANE_EVENT = "catalog-focus-pane";
/** Ask the shell to hand the keyboard back to the session list — what Escape
* does when the pane's terminal is focused. Handled in main.ts. */
export const FOCUS_SIDEBAR_EVENT = "catalog-focus-sidebar";
/** Ask the shell to open the spawn modal seeded from an existing session
* ("Spawn similar"). Detail is the session being cloned. */
export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar";
@@ -104,21 +100,6 @@ const STATE_WORDS: Record<string, string> = {
error: "Error",
};
// #168: dormancy is not an exit (docs/SIDEBAR.md), so `dataset.status` and
// the aria-label read the session's own three-way `status` rather than a
// two-way running/dead test that folded `Dormant` into "dead"/"exited".
function statusSlug(status: Session["status"]): string {
if (status === "Running") return "running";
if (status === "Dormant") return "dormant";
return "dead";
}
function statusWord(status: Session["status"]): string {
if (status === "Running") return "running";
if (status === "Dormant") return "dormant";
return "exited";
}
/** docs/SIDEBAR.md: the activity rail is pinned to the row's own left edge,
* full height. The row's own padding (`renderNode`) shifts outward with
* nesting depth, carrying the rail with it. */
@@ -128,9 +109,6 @@ function activityRailMarkup(slug: string, stateWord: string): string {
function metaLine(s: Session, now: number): string {
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 slug = activityColorSlug(s.activity ?? "Idle");
const held = activityHeldMs(s.activity_since, now);
@@ -145,7 +123,6 @@ function metaLine(s: Session, now: number): string {
commandFragment(s.command),
blockers ? blockedFragment(blockers) : "",
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>`;
}
@@ -218,46 +195,33 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
row.style.paddingLeft = `${depth * 16 + 8}px`;
row.dataset.sessionId = s.id;
row.dataset.navKey = `session:${s.id}`;
row.dataset.status = statusSlug(s.status);
// docs/SIDEBAR.md: a dormant card is a muted variant — nothing running
// behind it, so it earns no activity colour or spawn-similar affordance.
const dormant = s.status === "Dormant";
row.classList.toggle("is-dormant", dormant);
const running = s.status === "Running";
row.dataset.status = running ? "running" : "dead";
const name = sessionName(s);
const slug = activityColorSlug(s.activity ?? "Idle");
const stateWord = STATE_WORDS[slug] ?? STATE_WORDS.idle;
row.setAttribute("aria-label", `${name}, ${statusWord(s.status)}, ${stateWord}`);
const cloneOrResumeAction = dormant
? `<button class="leaf-act leaf-resume" type="button" title="Resume session" aria-label="Resume ${escapeHtml(name)}">↻</button>`
: `<button class="leaf-act leaf-clone" type="button" title="Spawn similar" aria-label="Spawn similar to ${escapeHtml(name)}">⧉</button>`;
row.setAttribute(
"aria-label",
`${name}, ${running ? "running" : "exited"}, ${stateWord}`,
);
// docs/SIDEBAR.md: line 1 reserves action width, and line 2 is unconditional.
row.innerHTML = `
${activityRailMarkup(slug, stateWord)}
<span class="card-line1">
<span class="leaf-name${isProvisionalName(s) ? " is-provisional" : ""}">${escapeHtml(name)}</span>
<span class="leaf-actions">
${cloneOrResumeAction}
<button class="leaf-act leaf-clone" type="button" title="Spawn similar" aria-label="Spawn similar to ${escapeHtml(name)}">⧉</button>
<button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${escapeHtml(name)}">✕</button>
</span>
</span>
${metaLine(s, now)}
`;
const similarBtn = row.querySelector<HTMLButtonElement>(".leaf-clone");
similarBtn?.addEventListener("click", (ev) => {
const similarBtn = row.querySelector(".leaf-clone") as HTMLButtonElement;
similarBtn.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur }));
});
const resumeBtn = row.querySelector<HTMLButtonElement>(".leaf-resume");
resumeBtn?.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
resumeBtn.disabled = true;
void resumeSession(cur.id).catch((e: Error) => {
resumeBtn.disabled = false;
alert(`Failed to resume: ${e.message}`);
});
});
const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement;
killBtn.addEventListener("click", (ev) => {
ev.preventDefault();
@@ -272,19 +236,11 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
ev.stopPropagation();
openContextMenu(ev.clientX, ev.clientY, [
{ label: "Rename…", onSelect: () => beginRename(row, cur) },
cur.status === "Dormant"
? {
label: "Resume",
onSelect: () =>
void resumeSession(cur.id).catch((e: Error) =>
alert(`Failed to resume: ${e.message}`),
),
}
: {
label: "Spawn similar",
onSelect: () =>
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur })),
},
{
label: "Spawn similar",
onSelect: () =>
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur })),
},
{
label: "Kill session",
danger: true,
@@ -296,8 +252,10 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
cur = next;
const nextSlug = activityColorSlug(next.activity ?? "Idle");
const nextWord = STATE_WORDS[nextSlug] ?? STATE_WORDS.idle;
row.dataset.status = statusSlug(next.status);
row.setAttribute("aria-label", `${sessionName(next)}, ${statusWord(next.status)}, ${nextWord}`);
row.setAttribute(
"aria-label",
`${sessionName(next)}, ${next.status === "Running" ? "running" : "exited"}, ${nextWord}`,
);
const rail = row.querySelector(".activity-rail");
if (rail) rail.outerHTML = activityRailMarkup(nextSlug, nextWord);
const meta = row.querySelector(".card-line2");
-14
View File
@@ -99,20 +99,6 @@ 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", () => {
const nulled = session("nulled", { last_focused_at: null });
const stamped = session("stamped", { last_focused_at: "2020-01-01T00:00:00Z" });
+3 -5
View File
@@ -142,10 +142,8 @@ function createdAt(s: Session): number {
* matcher's last tie-break preserves. */
export function orderSessionsForBar(sessions: readonly Session[]): Session[] {
return [...sessions].sort((a, b) => {
// docs/COMMAND-BAR.md: anything with no live pane — dead or dormant (#162)
// — sorts after the sessions you could actually attach to.
const deadA = a.status === "Running" ? 0 : 1;
const deadB = b.status === "Running" ? 0 : 1;
const deadA = a.status === "Dead" ? 1 : 0;
const deadB = b.status === "Dead" ? 1 : 0;
if (deadA !== deadB) return deadA - deadB;
const focusA = focusedAt(a);
@@ -352,7 +350,7 @@ function renderRow(result: BarResult, index: number): HTMLElement {
row.dataset.kind = candidate.kind;
if (candidate.kind === "session") {
row.dataset.sessionId = candidate.session.id;
if (candidate.session.status !== "Running") row.classList.add("is-dead");
if (candidate.session.status === "Dead") row.classList.add("is-dead");
} else if (isActionCandidate(candidate)) {
row.dataset.actionId = candidate.action.id;
} else if (candidate.kind === "command") {
+12 -14
View File
@@ -133,31 +133,28 @@ describe("CommandSet", () => {
expect(run).toHaveBeenCalledTimes(2);
});
it("keeps plain keys deaf inside a form field, and chorded ones live", () => {
it("keeps plain keys deaf inside a form field, and leader chords live from the pane", () => {
const plain = vi.fn();
const chorded = vi.fn();
const set = new CommandSet(
defineCommands({ "spawn.open": plain, "focus.toggle": chorded }),
defineCommands({ "spawn.open": plain, "focus.toggle": chorded }, DEFAULT_LEADER),
{ leader: DEFAULT_LEADER },
);
const input = el("<input>");
expect(pressOn(set, input, "n")).toBe(false);
expect(plain).not.toHaveBeenCalled();
expect(pressOn(set, input, "H", { ctrlKey: true, shiftKey: true })).toBe(true);
// focus.toggle hangs off the leader (#166): a plain, unmodified Escape is
// left for the pane's shell/app instead.
const terminal = el('<textarea class="xterm-helper-textarea"></textarea>');
pressOn(set, terminal, "a", { ctrlKey: true });
expect(pressOn(set, terminal, "h")).toBe(true);
expect(chorded).toHaveBeenCalledTimes(1);
});
it("keeps pane commands live inside the terminal's textarea", () => {
const run = vi.fn();
const set = new CommandSet(defineCommands({ "pane.focusList": run }), {
preventDefault: false,
});
it("leaves a plain Escape for the pane instead of binding it", () => {
const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }));
const textarea = el('<textarea class="xterm-helper-textarea"></textarea>');
const e = key("Escape");
Object.defineProperty(e, "target", { value: textarea });
expect(set.handle(e)).toBe(true);
expect(run).toHaveBeenCalledTimes(1);
// xterm is told to swallow by the return value, not by the browser default.
expect(e.defaultPrevented).toBe(false);
expect(pressOn(set, textarea, "Escape")).toBe(false);
});
});
@@ -173,6 +170,7 @@ describe("the 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"]);
expect(byId.get("focus.toggle")?.keys).toEqual(["ctrl-b h"]);
// Commands that do not hang off the leader are untouched.
expect(byId.get("bar.open")?.keys).toEqual(["ctrl-k"]);
});
+2 -8
View File
@@ -93,7 +93,8 @@ const SPECS: readonly SpecSource[] = [
id: "focus.toggle",
label: "Move the keyboard between the session list and the pane",
group: "Session list",
keys: ["ctrl-shift-h"],
keys: [`${DEFAULT_LEADER} h`],
leaderKeys: (leader) => [`${leader} h`],
scope: "global",
},
{
@@ -145,13 +146,6 @@ const SPECS: readonly SpecSource[] = [
keys: ["tab"],
scope: "list",
},
{
id: "pane.focusList",
label: "Hand the keyboard back to the session list, from the pane",
group: "Session list",
keys: ["esc"],
scope: "pane",
},
{
id: "help.toggle",
label: "Show or hide this help",
+2 -9
View File
@@ -25,13 +25,8 @@ export interface PoolOptions {
/** Fired when a connection's socket closes on its own (session ended/dropped),
* not via an intentional close/eviction. The embedder closes the pane. */
onClosed?: (id: Uuid) => void;
/** Fired when a connection's handshake never completed and so was never
* retried (see `AttachOptions.onNeverAttached`). Unlike `onClosed`, the
* embedder should not assume the session is gone — a dormant session's
* pane wants its resume affordance, not teardown. */
onNeverAttached?: (id: Uuid) => void;
/** Forwarded to each connection: should a dropped socket be retried? */
shouldReconnect?: (id: Uuid, code: number, everOpened: boolean) => boolean;
shouldReconnect?: (id: Uuid, code: number) => boolean;
}
/**
@@ -90,9 +85,7 @@ export class ConnectionPool {
{
onRequestClose: (sid) => this.opts.onRequestClose?.(sid),
onClosed: (sid) => this.opts.onClosed?.(sid),
onNeverAttached: (sid) => this.opts.onNeverAttached?.(sid),
shouldReconnect: (sid, code, everOpened) =>
this.opts.shouldReconnect?.(sid, code, everOpened) ?? false,
shouldReconnect: (sid, code) => this.opts.shouldReconnect?.(sid, code) ?? false,
},
info,
);
+33
View File
@@ -0,0 +1,33 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it } from "vitest";
import {
closeForceKillDialog,
openForceKillDialog,
openKillConfirmDialog,
} from "./killDialog.js";
describe("kill dialogs", () => {
beforeEach(() => {
closeForceKillDialog();
document.body.innerHTML = "";
});
it("focuses the kill button, so Enter confirms", () => {
const el = openKillConfirmDialog({
name: "alpha",
blockers: null,
onKill: () => Promise.resolve(),
});
expect(document.activeElement).toBe(el.querySelector(".kill-dialog-kill"));
});
it("focuses the force button too", () => {
const el = openForceKillDialog({
name: "alpha",
blockers: null,
reason: "worktree is dirty.",
onForce: () => Promise.resolve(),
});
expect(document.activeElement).toBe(el.querySelector(".kill-dialog-force"));
});
});
+3 -3
View File
@@ -132,9 +132,9 @@ function mount(p: KillDialogSpec): HTMLElement {
};
window.addEventListener("keydown", onKeydown, true);
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();
// The acting button takes the focus in both prompts: the operator already
// asked for the kill, so Enter confirms it. Escape and the scrim cancel.
confirm.focus();
return el;
}
+18
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
emptyLayout,
equalizeRatios,
leaves,
locateSession,
openSessions,
@@ -44,6 +45,23 @@ describe("splitLeaf", () => {
});
});
describe("equalizeRatios", () => {
it("resets nested splits to equal width, not just the newest one", () => {
const tab = tabWithOneLeaf("s1");
const first = leaves(tab.root)[0].id;
splitLeaf(tab, first, "s2", "row", true); // A|B, 0.5/0.5
const second = leaves(tab.root)[1].id;
splitLeaf(tab, second, "s3", "row", true); // A(0.5) | (B|C, 0.5/0.5) -> B,C at 0.25 each
equalizeRatios(tab.root);
const root = tab.root as Extract<typeof tab.root, { kind: "split" }>;
expect(root.ratio).toBeCloseTo(1 / 3);
const inner = root.b as Extract<typeof root.b, { kind: "split" }>;
expect(inner.ratio).toBeCloseTo(1 / 2);
});
});
describe("removeLeaf", () => {
it("collapses the parent split into the surviving sibling", () => {
const tab = tabWithOneLeaf("s1");
+11
View File
@@ -118,6 +118,17 @@ export function removeLeaf(tab: Tab, leafId: string): void {
tab.root = tab.root ? rec(tab.root) : null;
}
/** Reset every split ratio under `node` so all leaves get equal width,
* regardless of split direction or nesting order. Returns the leaf count. */
export function equalizeRatios(node: Node | null): number {
if (!node) return 0;
if (node.kind === "leaf") return 1;
const na = equalizeRatios(node.a);
const nb = equalizeRatios(node.b);
node.ratio = na / (na + nb);
return na + nb;
}
export function addTab(layout: Layout): Tab {
const tab: Tab = {
id: freshId("t"),
+8 -40
View File
@@ -19,7 +19,6 @@ import {
getDaemonInfo,
gitRoot,
projects as fetchProjects,
resumeSession,
} from "./api.js";
import {
buildFastForwardRequest,
@@ -36,7 +35,6 @@ import { loadLayout, saveLayout } from "./persistence.js";
import { createPreviewSchedule } from "./previewSchedule.js";
import {
FOCUS_PANE_EVENT,
FOCUS_SIDEBAR_EVENT,
OPEN_COMMAND_BAR_EVENT,
OPEN_SPAWN_EVENT,
SESSION_SELECT_EVENT,
@@ -173,42 +171,17 @@ const pool = new ConnectionPool({
// pane is never left lying around.
onRequestClose: (id) => closePane(id),
onClosed: (id) => closePane(id),
// #168 review: a handshake that never opened (a dormant session's `409
// not_attachable`, most likely) isn't "the session is gone" — drop the dead
// connection and re-render so the leaf gets a fresh attempt if the catalog
// still says Running, or the resume affordance if it doesn't.
onNeverAttached: (id) => {
pool.close(id);
mv?.render();
},
shouldReconnect: (id, code, everOpened) =>
shouldRetryClose(id, code, store.snapshot().sessions, everOpened),
shouldReconnect: (id, code) => shouldRetryClose(id, code, store.snapshot().sessions),
});
// Session death used to reach the UI on two uncoordinated channels — the row
// from the events socket, the pane from `ws.onclose` — so either could happen
// without the other. The catalog is the authority: a pane for a session that is
// no longer listed gets closed here.
//
// A pane can also be showing the dormant placeholder (or, briefly, a dead
// socket) rather than a live attach (#168): when an *open* session's
// Running-ness flips — most commonly a resume completing — the pane and the
// placeholder need to swap without waiting on an unrelated layout change.
let lastOpenRunning = new Map<string, boolean>();
store.subscribe((state) => {
for (const id of mv?.openSessions() ?? []) {
if (!state.sessions.has(id)) closePane(id);
}
const open = mv?.openSessions() ?? new Set<string>();
const nextRunning = new Map<string, boolean>();
let runningChanged = false;
for (const id of open) {
const running = state.sessions.get(id)?.status === "Running";
nextRunning.set(id, running);
if (lastOpenRunning.get(id) !== running) runningChanged = true;
}
lastOpenRunning = nextRunning;
if (runningChanged) mv?.render();
});
function closePane(id: string): void {
@@ -301,8 +274,6 @@ mv = createMultiView({
// catalog (no sessions) to attach.
onOpenSessionsChange: updateShellRoute,
onLayoutChange: scheduleSave,
sessionFor: (id) => store.snapshot().sessions.get(id),
onResume: (id) => resumeSession(id),
});
let contentShowsMv = false;
/** Put the multi-view in the content pane, without rendering it. A preview
@@ -468,10 +439,11 @@ const spawnKeys = new CommandSet(
}),
);
// Ctrl+Shift+H moves the keyboard between the session list and the pane, the
// web counterpart of the TUI's `ctrl-w h`. It has to be a chord: an attached
// terminal owns every plain keystroke, so nothing unmodified could ever get
// the focus back out of it.
// Leader-h moves the keyboard between the session list and the pane, the web
// counterpart of the TUI's `ctrl-w h`. It has to be a chord behind the
// leader: an attached terminal owns every plain keystroke, and Ctrl+Shift+H
// is already claimed by the browser (history), so nothing unmodified — or
// modified with a common browser chord — could get the focus back out of it.
//
// This set also owns the leader (`[tui].leader`, `ctrl-a` by default, served
// on `/api/info`): it holds the key as a partial chord and takes select-all off
@@ -556,8 +528,8 @@ window.addEventListener(
);
// Tab in the session list hands the keyboard to the pane; Escape in the pane
// hands it back — the pair the TUI gets from `tab` and `esc`.
// Tab in the session list hands the keyboard to the pane; leader-h (focus.toggle,
// above) hands it back the other way.
document.addEventListener(FOCUS_PANE_EVENT, () => {
// Handing the keyboard to a previewed pane is choosing it: the preview stops
// being provisional rather than snapping back the moment the list blurs.
@@ -565,10 +537,6 @@ document.addEventListener(FOCUS_PANE_EVENT, () => {
if (previewed) focusSession(previewed);
pool.focusTerminal();
});
document.addEventListener(FOCUS_SIDEBAR_EVENT, () => {
drawer.open(); // no-op on desktop; on mobile the list is off-canvas
sidebar.focusList();
});
/** Open/focus a session and reflect it in the shell chrome. Shared by the
* hash router and the sidebar's click-driven path (#133), which can't rely
+1 -98
View File
@@ -45,7 +45,7 @@ function fakeSidebar(): SidebarHandle {
};
}
function makeMv(layout: Layout, overrides: Partial<Parameters<typeof createMultiView>[0]> = {}): MultiView {
function makeMv(layout: Layout): MultiView {
return createMultiView({
pool: fakePool(),
layout,
@@ -53,7 +53,6 @@ function makeMv(layout: Layout, overrides: Partial<Parameters<typeof createMulti
infoFor: () => ({ name: "sess", command: "bash" }),
onDisconnect: () => {},
renderWelcome: () => document.createElement("div"),
...overrides,
});
}
@@ -88,102 +87,6 @@ describe("multiview empty panes", () => {
});
});
// #168 review: highlighting a dormant session used to open a pane and attempt
// a live attach, which the daemon answers with 404/409 and the socket dies
// with WS 1006. A dormant leaf must never reach the pool at all — it gets the
// resume affordance instead.
describe("multiview dormant gating (#168)", () => {
beforeEach(() => {
stubFetch();
document.body.replaceChildren();
});
afterEach(() => {
vi.unstubAllGlobals();
});
function dormantSession(id: string) {
return {
id,
cwd: "/home/n/work",
command: "claude",
env: {},
metadata: { name: "worker" },
status: "Dormant" as const,
created_at: "2026-08-24T10:00:00Z",
};
}
it("never hands a dormant session's leaf to the pool, and shows a resume affordance", async () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
tab.focusedLeafId = splitLeaf(tab, "", "d1", "row", true);
const setVisible = vi.fn();
const pool = { setVisible, setFocus() {} } as unknown as ConnectionPool;
const mv = createMultiView({
pool,
layout,
sidebar: fakeSidebar(),
infoFor: () => ({ name: "worker", command: "claude" }),
onDisconnect: () => {},
renderWelcome: () => document.createElement("div"),
sessionFor: (id) => (id === "d1" ? dormantSession("d1") : undefined),
});
document.body.appendChild(mv.el);
mv.render();
await flush();
expect(setVisible).toHaveBeenCalledWith([], expect.anything());
const pane = mv.el.querySelector('[data-leaf-id]') as HTMLElement;
expect(pane.querySelector(".mv-pane-dormant")).toBeTruthy();
expect(pane.textContent).toContain("worker");
expect(pane.textContent).toContain("/home/n/work");
expect(pane.querySelector(".mv-dormant-resume")).toBeTruthy();
});
it("still attaches a Running session's leaf normally", async () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
tab.focusedLeafId = splitLeaf(tab, "", "r1", "row", true);
const setVisible = vi.fn();
const pool = { setVisible, setFocus() {} } as unknown as ConnectionPool;
const mv = createMultiView({
pool,
layout,
sidebar: fakeSidebar(),
infoFor: () => ({ name: "worker", command: "claude" }),
onDisconnect: () => {},
renderWelcome: () => document.createElement("div"),
sessionFor: (id) => ({ ...dormantSession(id), status: "Running" }),
});
document.body.appendChild(mv.el);
mv.render();
await flush();
expect(setVisible).toHaveBeenCalledWith(
[expect.objectContaining({ id: "r1" })],
expect.anything(),
);
});
it("clicking resume calls back with the session id", async () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
tab.focusedLeafId = splitLeaf(tab, "", "d1", "row", true);
const onResume = vi.fn().mockResolvedValue(undefined);
const mv = makeMv(layout, {
sessionFor: (id) => (id === "d1" ? dormantSession("d1") : undefined),
onResume,
});
document.body.appendChild(mv.el);
mv.render();
await flush();
mv.el.querySelector<HTMLButtonElement>(".mv-dormant-resume")?.click();
expect(onResume).toHaveBeenCalledWith("d1");
});
});
describe("multiview pane-drag swap", () => {
beforeEach(() => {
stubFetch();
+6 -61
View File
@@ -6,14 +6,11 @@
import type { SidebarHandle } from "./catalog.js";
import type { ConnectionPool } from "./connections.js";
import { activityDurationLabel, activityHeldMs } from "./activity.js";
import { escapeHtml } from "./html.js";
import { sessionName } from "./types.js";
import type { Session } from "./types.js";
import {
activeTab,
addTab,
closeTab,
equalizeRatios,
findLeaf,
freshId,
type Dir,
@@ -38,14 +35,6 @@ export interface MultiViewOptions {
onDisconnect: (sessionId: string) => void;
/** Welcome content for an empty tab. */
renderWelcome: () => HTMLElement;
/** The session behind a leaf, when known — used to gate a live attach on
* `status === "Running"` (#168 review) and to fill in the dormant pane's
* resume affordance. A leaf whose session this returns `undefined` for
* (unknown to the caller) attaches as before, so callers that don't wire
* this up see no change in behaviour. */
sessionFor?: (sessionId: string) => Session | undefined;
/** Resume a dormant session (#166/#168), from its pane's own affordance. */
onResume?: (sessionId: string) => Promise<unknown>;
/** Called when a spawn fills a pane with a new session, so the shell can
* re-evaluate its route (sidebar vs attach layout). */
onOpenSessionsChange?: () => void;
@@ -274,44 +263,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
persist();
}
// ---- dormant panes (#168 review) ----
/** The session behind `sessionId` when it is known and not `Running` —
* what gates a leaf away from a live attach and towards the resume
* affordance instead. */
function dormantSessionFor(sessionId: string): Session | undefined {
const s = opts.sessionFor?.(sessionId);
return s && s.status !== "Running" ? s : undefined;
}
/** docs/PREVIEW.md / docs/SIDEBAR.md: what the content pane can honestly
* show for a session with no pane behind it — its metadata and a resume
* affordance, never a live terminal that would just die with WS 1006. */
function renderDormantPane(host: HTMLElement, session: Session): void {
const heldMs = activityHeldMs(session.last_activity_at ?? session.created_at, Date.now());
const age = heldMs !== null ? activityDurationLabel(heldMs) : null;
const wrap = document.createElement("div");
wrap.className = "mv-pane-dormant";
wrap.innerHTML = `
<div class="mv-dormant-glyph" aria-hidden="true">◌</div>
<div class="mv-dormant-name">${escapeHtml(sessionName(session))}</div>
<div class="mv-dormant-cwd" title="${escapeHtml(session.cwd)}">${escapeHtml(session.cwd)}</div>
<div class="mv-dormant-detail">dormant${age ? ` for ${escapeHtml(age)}` : ""} — nothing running behind it</div>
<button type="button" class="btn btn-primary mv-dormant-resume">Resume</button>
`;
const btn = wrap.querySelector(".mv-dormant-resume") as HTMLButtonElement;
btn.addEventListener("click", () => {
btn.disabled = true;
btn.textContent = "Resuming…";
opts.onResume?.(session.id)?.catch((e: Error) => {
btn.disabled = false;
btn.textContent = "Resume";
alert(`Failed to resume: ${e.message}`);
});
});
host.replaceChildren(wrap);
}
// ---- pane + split DOM ----
function buildPane(leaf: Extract<Node, { kind: "leaf" }>): {
@@ -428,12 +379,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
if (node.kind === "leaf") {
const pane = buildPane(node);
if (node.sessionId) {
const dormant = dormantSessionFor(node.sessionId);
if (dormant) {
renderDormantPane(pane.host, dormant);
} else {
hosts.push({ id: node.sessionId, host: pane.host, info: infoFor(node.sessionId) });
}
hosts.push({ id: node.sessionId, host: pane.host, info: infoFor(node.sessionId) });
}
return pane.el;
}
@@ -512,6 +458,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
dropPreview();
const tab = activeTab(layout);
tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true);
equalizeRatios(tab.root);
render();
}
@@ -597,6 +544,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
target.sessionId = sessionId;
} else {
tab.focusedLeafId = splitLeaf(tab, target.id, sessionId, "row", true);
equalizeRatios(tab.root);
}
render();
opts.onOpenSessionsChange?.();
@@ -671,13 +619,9 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
wrap.appendChild(pane.el);
canvas.appendChild(wrap);
const dormant = focused.sessionId ? dormantSessionFor(focused.sessionId) : undefined;
if (dormant) renderDormantPane(pane.host, dormant);
// Only the focused leaf's session is live on mobile; the rest park.
pool.setVisible(
focused.sessionId && !dormant
? [{ id: focused.sessionId, pane: pane.host, info: infoFor(focused.sessionId) }]
: [],
focused.sessionId ? [{ id: focused.sessionId, pane: pane.host, info: infoFor(focused.sessionId) }] : [],
{ focus: !preview && !quietRender },
);
if (!preview) pool.setFocus(focused.sessionId ?? null);
@@ -874,6 +818,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
if (source.kind === "pane" && self) removeLeaf(tab, self);
else if (loc) removeLeaf(loc.tab, loc.leaf.id);
tab.focusedLeafId = splitLeaf(tab, targetLeafId, sessionId, dir, after);
equalizeRatios(tab.root);
render();
}
-7
View File
@@ -47,13 +47,6 @@ describe("shouldRetryClose", () => {
it("never retries a clean close", () => {
expect(shouldRetryClose("a", 1000, running)).toBe(false);
});
// #168 review: a handshake that never opened is a rejection (e.g. a
// dormant session's 409 not_attachable), not a dropped stream. Retrying it
// loops forever if the catalog status hasn't caught up yet.
it("never retries a socket that never opened, even if the catalog still says running", () => {
expect(shouldRetryClose("a", 1006, running, false)).toBe(false);
});
});
describe("backoff bounds", () => {
-8
View File
@@ -26,15 +26,7 @@ export function shouldRetryClose(
sessionId: Uuid,
code: number,
sessions: ReadonlyMap<Uuid, Session>,
/** Did this socket ever reach `open`? A handshake that never completed is
* the daemon rejecting the attach (dormant/unknown) rather than a dropped
* stream — retrying it spins in place until the catalog updates, which for
* a dormant session it never will (#168 review). Defaults to `true` so
* existing callers that only track drops of an established link are
* unaffected. */
everOpened = true,
): boolean {
if (code >= APPLICATION_CLOSE || code === CLEAN_CLOSE) return false;
if (!everOpened) return false;
return sessions.get(sessionId)?.status === "Running";
}
-6
View File
@@ -37,12 +37,6 @@ export function sessionFocus(id: Uuid): string {
return `${session(id)}/focus`;
}
/** `POST /api/sessions/:id/resume` (#166/#168): respawn a dormant session
* under its own uuid and sidecar. */
export function sessionResume(id: Uuid): string {
return `${session(id)}/resume`;
}
/** Append the `path` query parameter `DIRS` and `GITROOT` accept. */
export function withPathQuery(route: string, value: string): string {
return `${route}?${PATH_QUERY}=${encodeURIComponent(value)}`;
+4 -45
View File
@@ -1210,16 +1210,12 @@ textarea {
}
/* Exited sessions recede: dimmed name + an "exited" badge carry status, so it's
never the running tint's absence alone doing the work. A dormant session
(#168, docs/SIDEBAR.md) reads the same muted way — it is known, not gone,
but there is nothing running behind it either. */
.tree-leaf[data-status="dead"],
.tree-leaf.is-dormant {
never the running tint's absence alone doing the work. */
.tree-leaf[data-status="dead"] {
color: var(--muted);
}
.tree-leaf[data-status="dead"] .leaf-name,
.tree-leaf.is-dormant .leaf-name {
.tree-leaf[data-status="dead"] .leaf-name {
color: var(--muted);
font-weight: 400;
}
@@ -1758,8 +1754,7 @@ input.leaf-rename:focus {
}
}
.leaf-clone:hover,
.leaf-resume:hover {
.leaf-clone:hover {
color: var(--accent);
background: var(--accent-tint);
border-color: color-mix(in oklab, var(--accent) 40%, transparent);
@@ -3270,42 +3265,6 @@ input.leaf-rename:focus {
font-size: var(--fs-13);
}
/* #168: what a pane shows for a dormant session — its metadata and a resume
action, in place of the live terminal there is nothing to attach to. */
.mv-pane-dormant {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--sp-2);
padding: var(--sp-4);
text-align: center;
color: var(--muted);
}
.mv-dormant-glyph {
font-size: 28px;
color: var(--faint);
}
.mv-dormant-name {
font-size: var(--fs-15);
font-weight: 500;
color: var(--ink);
}
.mv-dormant-cwd {
font-size: var(--fs-12);
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mv-dormant-detail {
font-size: var(--fs-12);
}
.mv-dormant-resume {
margin-top: var(--sp-2);
}
/* pane tools cluster (disconnect / split / close), top-right corner. The attach
header's own disconnect button is hidden — ⏻ lives here as the leftmost tool. */
.mv-pane-tools {
+1 -37
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { buildTree, compareNodes, mergeLinear, type TreeFolder, type TreeNode } from "./tree.js";
import { buildTree, mergeLinear, type TreeFolder, type TreeNode } from "./tree.js";
import type { Session } from "./types.js";
function session(id: string, cwd: string, command = "bash"): Session {
@@ -67,42 +67,6 @@ describe("buildTree", () => {
expect(root.children[0].kind).toBe("folder");
expect(root.children[1].kind).toBe("leaf");
});
// #168, docs/SIDEBAR.md: a dormant session takes the lowest precedence in
// the ordering, so live work always sorts above it, even a name that would
// otherwise sort first.
it("sorts a dormant session after live ones in the same folder, name notwithstanding", () => {
const dormant = { ...session("aaa-dormant", "/home/n/work"), status: "Dormant" } as Session;
const live = session("zzz-live", "/home/n/work");
const root = buildTree([dormant, live], "/home/n") as TreeFolder;
const folder = root.children[0] as TreeFolder;
const ids = folder.children.map((c) => (c.kind === "leaf" ? c.session.id : c.name));
expect(ids).toEqual(["zzz-live", "aaa-dormant"]);
});
it("still sorts dormant sessions by name against each other", () => {
const a = {
...session("a", "/home/n/work"),
status: "Dormant",
metadata: { name: "a" },
} as Session;
const b = {
...session("b", "/home/n/work"),
status: "Dormant",
metadata: { name: "b" },
} as Session;
const root = buildTree([b, a], "/home/n") as TreeFolder;
const folder = root.children[0] as TreeFolder;
const ids = folder.children.map((c) => (c.kind === "leaf" ? c.session.id : c.name));
expect(ids).toEqual(["a", "b"]);
});
it("exposes the leaf/name precedence directly via compareNodes", () => {
const dormant: TreeNode = { kind: "leaf", session: { ...session("a", "/x"), status: "Dormant" } };
const live: TreeNode = { kind: "leaf", session: session("b", "/x") };
expect(compareNodes(dormant, live)).toBeGreaterThan(0);
expect(compareNodes(live, dormant)).toBeLessThan(0);
});
});
describe("mergeLinear", () => {
-8
View File
@@ -18,16 +18,8 @@ export interface TreeLeaf {
export type TreeNode = TreeFolder | TreeLeaf;
/** docs/SIDEBAR.md: a dormant session takes the lowest precedence in the
* ordering, so live work always sorts above it regardless of name. */
function dormantRank(node: TreeNode): number {
return node.kind === "leaf" && node.session.status === "Dormant" ? 1 : 0;
}
export function compareNodes(a: TreeNode, b: TreeNode): number {
if (a.kind !== b.kind) return a.kind === "folder" ? -1 : 1;
const rank = dormantRank(a) - dormantRank(b);
if (rank !== 0) return rank;
const an = a.kind === "folder" ? a.name : sessionName(a.session);
const bn = b.kind === "folder" ? b.name : sessionName(b.session);
return an.localeCompare(bn);
+1 -4
View File
@@ -6,10 +6,7 @@ import { escapeHtml } from "./html.js";
export type Uuid = string;
export type IsoTimestamp = string;
/** `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";
export type SessionStatus = "Running" | "Dead";
/** Daemon-inferred activity dimension, orthogonal to `SessionStatus` (NAP-340).
* Absent on payloads from daemons that predate the field. `Error` is only set