Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4851ad9101 | ||
|
|
f0c272e0b1 | ||
|
|
624f6f19c8 | ||
|
|
08161ae042 | ||
|
|
1e3c7b294d | ||
|
|
49ab7e9c81 | ||
|
|
0cc25d7e1c | ||
|
|
706942ddd7 | ||
|
|
52877abe29 | ||
|
|
43fc3a3735 | ||
|
|
ff1ffb377c | ||
|
|
ba74d3515b |
@@ -96,20 +96,32 @@ pub async fn dispatch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `maestro hooks install` (#106): write the shipped hook script under the
|
/// `maestro hooks install` (#106): write the shipped hook script under the
|
||||||
/// user's data dir and register it in their Claude Code settings. Idempotent
|
/// user's data dir and register it in every agent's hook file. Idempotent and
|
||||||
/// and foreign-hook-preserving — the guarantees live in [`maestro_hooks`].
|
/// 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)]
|
#[allow(clippy::result_large_err)]
|
||||||
fn run_hooks_install() -> Result<(), CliError> {
|
fn run_hooks_install() -> Result<(), CliError> {
|
||||||
let home = dirs::home_dir().ok_or(CliError::NoHome)?;
|
let home = dirs::home_dir().ok_or(CliError::NoHome)?;
|
||||||
let changed = maestro_hooks::install_for_home(&home)?;
|
let script = maestro_hooks::script_path(&home);
|
||||||
if changed {
|
for (target, result) in maestro_hooks::install_for_home(&home) {
|
||||||
println!(
|
let agent = target.label();
|
||||||
"installed claude-code activity hook: {} → {}",
|
let settings = target.settings_path(&home);
|
||||||
maestro_hooks::script_path(&home).display(),
|
match result {
|
||||||
maestro_hooks::settings_path(&home).display()
|
Ok(true) => {
|
||||||
);
|
println!(
|
||||||
} else {
|
"installed {agent} activity hook: {} → {}",
|
||||||
println!("claude-code activity hook already up to date");
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,7 +188,6 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
|||||||
let status = match s.status {
|
let status = match s.status {
|
||||||
SessionStatus::Running => "running",
|
SessionStatus::Running => "running",
|
||||||
SessionStatus::Dead => "dead",
|
SessionStatus::Dead => "dead",
|
||||||
SessionStatus::Dormant => "dormant",
|
|
||||||
};
|
};
|
||||||
let activity = match s.activity {
|
let activity = match s.activity {
|
||||||
ActivityState::Active => "active",
|
ActivityState::Active => "active",
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ async fn info_carries_version_and_presets() {
|
|||||||
id: "claude".into(),
|
id: "claude".into(),
|
||||||
label: "Claude".into(),
|
label: "Claude".into(),
|
||||||
command: "claude".into(),
|
command: "claude".into(),
|
||||||
resume_command: None,
|
|
||||||
}],
|
}],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -313,19 +313,16 @@ pub fn builtin_presets() -> Vec<SpawnPreset> {
|
|||||||
id: "claude".into(),
|
id: "claude".into(),
|
||||||
label: "Claude".into(),
|
label: "Claude".into(),
|
||||||
command: "claude".into(),
|
command: "claude".into(),
|
||||||
resume_command: Some("claude --resume {agent_session}".into()),
|
|
||||||
},
|
},
|
||||||
SpawnPreset {
|
SpawnPreset {
|
||||||
id: "opencode".into(),
|
id: "opencode".into(),
|
||||||
label: "opencode".into(),
|
label: "opencode".into(),
|
||||||
command: "opencode".into(),
|
command: "opencode".into(),
|
||||||
resume_command: None,
|
|
||||||
},
|
},
|
||||||
SpawnPreset {
|
SpawnPreset {
|
||||||
id: "zsh".into(),
|
id: "zsh".into(),
|
||||||
label: "Shell".into(),
|
label: "Shell".into(),
|
||||||
command: "zsh".into(),
|
command: "zsh".into(),
|
||||||
resume_command: None,
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -337,7 +334,6 @@ pub fn custom_preset() -> SpawnPreset {
|
|||||||
id: CUSTOM_PRESET_ID.into(),
|
id: CUSTOM_PRESET_ID.into(),
|
||||||
label: "Custom…".into(),
|
label: "Custom…".into(),
|
||||||
command: String::new(),
|
command: String::new(),
|
||||||
resume_command: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -511,18 +507,6 @@ auto_install = false
|
|||||||
assert!(custom.command.is_empty());
|
assert!(custom.command.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn builtin_claude_preset_resumes_by_agent_session() {
|
|
||||||
let claude = builtin_presets()
|
|
||||||
.into_iter()
|
|
||||||
.find(|preset| preset.id == "claude")
|
|
||||||
.expect("Claude preset");
|
|
||||||
assert_eq!(
|
|
||||||
claude.resume_command.as_deref(),
|
|
||||||
Some("claude --resume {agent_session}")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn spawn_presets_override_replaces_builtins() {
|
fn spawn_presets_override_replaces_builtins() {
|
||||||
// Present list replaces the built-ins entirely; custom still appended.
|
// Present list replaces the built-ins entirely; custom still appended.
|
||||||
@@ -531,7 +515,6 @@ auto_install = false
|
|||||||
id: "fish".into(),
|
id: "fish".into(),
|
||||||
label: "fish".into(),
|
label: "fish".into(),
|
||||||
command: "fish".into(),
|
command: "fish".into(),
|
||||||
resume_command: None,
|
|
||||||
}]),
|
}]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -568,7 +551,6 @@ auto_install = false
|
|||||||
id: " ".into(),
|
id: " ".into(),
|
||||||
label: "x".into(),
|
label: "x".into(),
|
||||||
command: "x".into(),
|
command: "x".into(),
|
||||||
resume_command: None,
|
|
||||||
}]),
|
}]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -579,7 +561,6 @@ auto_install = false
|
|||||||
id: "x".into(),
|
id: "x".into(),
|
||||||
label: "x".into(),
|
label: "x".into(),
|
||||||
command: "".into(),
|
command: "".into(),
|
||||||
resume_command: None,
|
|
||||||
}]),
|
}]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -597,13 +578,11 @@ auto_install = false
|
|||||||
id: "a".into(),
|
id: "a".into(),
|
||||||
label: "a".into(),
|
label: "a".into(),
|
||||||
command: "a".into(),
|
command: "a".into(),
|
||||||
resume_command: None,
|
|
||||||
},
|
},
|
||||||
SpawnPreset {
|
SpawnPreset {
|
||||||
id: "a".into(),
|
id: "a".into(),
|
||||||
label: "a2".into(),
|
label: "a2".into(),
|
||||||
command: "a2".into(),
|
command: "a2".into(),
|
||||||
resume_command: None,
|
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -618,7 +597,6 @@ auto_install = false
|
|||||||
id: "custom".into(),
|
id: "custom".into(),
|
||||||
label: "Custom".into(),
|
label: "Custom".into(),
|
||||||
command: "whatever".into(),
|
command: "whatever".into(),
|
||||||
resume_command: None,
|
|
||||||
}]),
|
}]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -654,27 +632,6 @@ presets = [
|
|||||||
assert_eq!(ids, ["claude", "zsh", "custom"]);
|
assert_eq!(ids, ["claude", "zsh", "custom"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn spawn_preset_parses_resume_command_from_issue_example() {
|
|
||||||
let cfg = toml::from_str::<MaestroConfigFile>(
|
|
||||||
r#"
|
|
||||||
[spawn]
|
|
||||||
presets = [
|
|
||||||
{ id = "claude", command = "claude --dangerously-skip-permissions", resume_command = "claude --dangerously-skip-permissions --resume {agent_session}" }
|
|
||||||
]
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.expect("issue #165 example parses");
|
|
||||||
|
|
||||||
let preset = &cfg.spawn.presets.expect("configured presets")[0];
|
|
||||||
assert_eq!(preset.id, "claude");
|
|
||||||
assert_eq!(preset.command, "claude --dangerously-skip-permissions");
|
|
||||||
assert_eq!(
|
|
||||||
preset.resume_command.as_deref(),
|
|
||||||
Some("claude --dangerously-skip-permissions --resume {agent_session}")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn spawn_project_scan_fields_default_to_none() {
|
fn spawn_project_scan_fields_default_to_none() {
|
||||||
// Absent [spawn].project_* → None; the daemon applies its own defaults.
|
// Absent [spawn].project_* → None; the daemon applies its own defaults.
|
||||||
|
|||||||
@@ -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
|
// First-start hook auto-install (#106). Best-effort, before serving: a
|
||||||
// session spawned right away should already carry the hook.
|
// session spawned right away should already carry the hook.
|
||||||
if config.hooks_auto_install {
|
if config.hooks_auto_install {
|
||||||
match crate::hooks::auto_install(&config.home_dir) {
|
for (target, outcome) in crate::hooks::auto_install(&config.home_dir) {
|
||||||
crate::hooks::AutoInstallOutcome::Installed => {
|
let agent = target.label();
|
||||||
tracing::info!("installed claude-code activity hook (#106)");
|
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
|
// No worktree dir to pre-create: without an override the worktrees live
|
||||||
|
|||||||
@@ -1,37 +1,53 @@
|
|||||||
//! First-start hook auto-install (#106).
|
//! First-start hook auto-install (#106).
|
||||||
//!
|
//!
|
||||||
//! The daemon runs [`auto_install`] once at startup: unless the user's
|
//! The daemon runs [`auto_install`] once at startup: for every agent maestro
|
||||||
//! `~/.claude/settings.json` already carries exactly what this build ships
|
//! knows how to hook ([`maestro_hooks::Target`]), unless that agent's hook
|
||||||
//! (and `[hooks].auto_install` hasn't opted out — checked by the caller), the
|
//! file already carries exactly what this build ships (and `[hooks]
|
||||||
//! same installer `maestro hooks install` uses writes it. That covers
|
//! .auto_install` hasn't opted out — checked by the caller), the same
|
||||||
//! upgrades as well as first starts: an install from a maestro that had fewer
|
//! installer `maestro hooks install` uses writes it. That covers upgrades as
|
||||||
//! events — #131 added `UserPromptSubmit` to a hook #106 had already put in
|
//! well as first starts: an install from a maestro that had fewer events —
|
||||||
//! everyone's settings — is rewritten rather than left half-wired. Best-effort:
|
//! #131 added `UserPromptSubmit` to a hook #106 had already put in everyone's
|
||||||
//! a failure is warned about and never blocks startup. The same global hook
|
//! settings — is rewritten rather than left half-wired. Best-effort: a
|
||||||
//! serves every Claude Code session on the machine, so nothing here targets
|
//! failure is warned about and never blocks startup, and one target failing
|
||||||
//! repositories or worktrees.
|
//! 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;
|
use std::path::Path;
|
||||||
|
|
||||||
/// Install the Claude Code activity hook for `home_dir` unless the current
|
use maestro_hooks::Target;
|
||||||
/// one is already there or the home dir is unresolvable (`""`). Returns what
|
|
||||||
/// happened; an older install being brought up to date is
|
/// Install the activity hook for every [`Target`] under `home_dir`, skipping
|
||||||
/// [`AutoInstallOutcome::Upgraded`], so the log can say so.
|
/// any whose install is already current. Returns what happened per target —
|
||||||
pub fn auto_install(home_dir: &str) -> AutoInstallOutcome {
|
/// 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() {
|
if home_dir.is_empty() {
|
||||||
return AutoInstallOutcome::Skipped;
|
return AutoInstallOutcome::Skipped;
|
||||||
}
|
}
|
||||||
let settings = maestro_hooks::settings_path(Path::new(home_dir));
|
let home = Path::new(home_dir);
|
||||||
let script = maestro_hooks::script_path(Path::new(home_dir));
|
let settings = target.settings_path(home);
|
||||||
if maestro_hooks::is_current(&settings, &script) {
|
let script = maestro_hooks::script_path(home);
|
||||||
|
if maestro_hooks::is_current(target, &settings, &script) {
|
||||||
return AutoInstallOutcome::AlreadyPresent;
|
return AutoInstallOutcome::AlreadyPresent;
|
||||||
}
|
}
|
||||||
let stale = maestro_hooks::is_installed(&settings);
|
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(_) if stale => AutoInstallOutcome::Upgraded,
|
||||||
Ok(_) => AutoInstallOutcome::Installed,
|
Ok(_) => AutoInstallOutcome::Installed,
|
||||||
Err(e) => {
|
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
|
AutoInstallOutcome::Failed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,15 +68,50 @@ pub enum AutoInstallOutcome {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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
|
/// 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]
|
#[test]
|
||||||
fn installs_when_settings_carry_no_maestro_hook() {
|
fn installs_when_settings_carry_no_maestro_hook() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let outcome = auto_install(dir.path().to_str().unwrap());
|
let outcomes = auto_install(dir.path().to_str().unwrap());
|
||||||
assert_eq!(outcome, AutoInstallOutcome::Installed);
|
for target in Target::ALL {
|
||||||
let settings = dir.path().join(".claude/settings.json");
|
assert_eq!(
|
||||||
assert!(maestro_hooks::is_installed(&settings));
|
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]
|
#[test]
|
||||||
@@ -69,14 +120,22 @@ mod tests {
|
|||||||
let home = dir.path().to_str().unwrap();
|
let home = dir.path().to_str().unwrap();
|
||||||
auto_install(home);
|
auto_install(home);
|
||||||
// A current install is left exactly as it is.
|
// A current install is left exactly as it is.
|
||||||
let settings = dir.path().join(".claude/settings.json");
|
let before: Vec<String> = Target::ALL
|
||||||
let before = std::fs::read_to_string(&settings).unwrap();
|
.iter()
|
||||||
assert_eq!(auto_install(home), AutoInstallOutcome::AlreadyPresent);
|
.map(|t| std::fs::read_to_string(t.settings_path(dir.path())).unwrap())
|
||||||
assert_eq!(
|
.collect();
|
||||||
before,
|
let outcomes = auto_install(home);
|
||||||
std::fs::read_to_string(&settings).unwrap(),
|
for (i, target) in Target::ALL.iter().enumerate() {
|
||||||
"already-installed settings must not be rewritten"
|
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
|
/// An install from an older maestro — present, but missing an event this
|
||||||
@@ -87,7 +146,7 @@ mod tests {
|
|||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let home = dir.path().to_str().unwrap();
|
let home = dir.path().to_str().unwrap();
|
||||||
auto_install(home);
|
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 script = maestro_hooks::script_path(dir.path());
|
||||||
|
|
||||||
let mut root: serde_json::Value =
|
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(&settings, serde_json::to_string_pretty(&root).unwrap()).unwrap();
|
||||||
std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap();
|
std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap();
|
||||||
|
|
||||||
assert_eq!(auto_install(home), AutoInstallOutcome::Upgraded);
|
let outcomes = auto_install(home);
|
||||||
assert!(maestro_hooks::is_current(&settings, &script));
|
assert_eq!(
|
||||||
|
outcome(&outcomes, Target::ClaudeCode),
|
||||||
|
AutoInstallOutcome::Upgraded
|
||||||
|
);
|
||||||
|
assert!(maestro_hooks::is_current(
|
||||||
|
Target::ClaudeCode,
|
||||||
|
&settings,
|
||||||
|
&script
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unresolvable_home_is_skipped() {
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,10 +62,7 @@ pub struct SessionRecord {
|
|||||||
/// `base_repo` from the original `WorktreeSpec`. Needed because `git
|
/// `base_repo` from the original `WorktreeSpec`. Needed because `git
|
||||||
/// worktree remove` is invoked from the base repo.
|
/// worktree remove` is invoked from the base repo.
|
||||||
pub worktree_base_repo: Option<PathBuf>,
|
pub worktree_base_repo: Option<PathBuf>,
|
||||||
/// The PTY bridge feeding this session's attach stream. `None` for a
|
pub bridge: PtyBridge,
|
||||||
/// `Dormant` session (#162): there is no pane behind it, so there is
|
|
||||||
/// nothing to pipe and nothing to attach to.
|
|
||||||
pub bridge: Option<PtyBridge>,
|
|
||||||
/// Agent-reported activity (#105) still within its TTL. While in force it
|
/// Agent-reported activity (#105) still within its TTL. While in force it
|
||||||
/// outranks the activity pass's own inference; when it lapses, inference
|
/// outranks the activity pass's own inference; when it lapses, inference
|
||||||
/// resumes and [`Session::activity_detail`] is cleared with it. This
|
/// resumes and [`Session::activity_detail`] is cleared with it. This
|
||||||
@@ -294,10 +291,7 @@ impl SessionManager {
|
|||||||
/// unknown to this daemon.
|
/// unknown to this daemon.
|
||||||
pub async fn subscribe_pty(&self, id: Uuid) -> Option<broadcast::Receiver<Vec<u8>>> {
|
pub async fn subscribe_pty(&self, id: Uuid) -> Option<broadcast::Receiver<Vec<u8>>> {
|
||||||
let sessions = self.inner.sessions.lock().await;
|
let sessions = self.inner.sessions.lock().await;
|
||||||
sessions
|
sessions.get(&id).map(|r| r.bridge.subscribe())
|
||||||
.get(&id)
|
|
||||||
.and_then(|r| r.bridge.as_ref())
|
|
||||||
.map(|b| b.subscribe())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Subscribe to a session's PTY broadcast and return the replay buffer
|
/// Subscribe to a session's PTY broadcast and return the replay buffer
|
||||||
@@ -316,26 +310,19 @@ impl SessionManager {
|
|||||||
let (result, updated) = {
|
let (result, updated) = {
|
||||||
let mut sessions = self.inner.sessions.lock().await;
|
let mut sessions = self.inner.sessions.lock().await;
|
||||||
let rec = sessions.get_mut(&id)?;
|
let rec = sessions.get_mut(&id)?;
|
||||||
let SessionRecord {
|
rec.session.last_focused_at = Some(Utc::now());
|
||||||
session, bridge, ..
|
let updated = rec.session.clone();
|
||||||
} = &mut *rec;
|
|
||||||
// #162: a dormant session has no bridge and no pane — nothing was
|
|
||||||
// ever piped for it, so there is no stream to hand back and the
|
|
||||||
// attach reads as unknown. Checked before anything is stamped.
|
|
||||||
let bridge = bridge.as_ref()?;
|
|
||||||
session.last_focused_at = Some(Utc::now());
|
|
||||||
let updated = session.clone();
|
|
||||||
// For a live session, tmux's own screen beats the byte tail: the
|
// For a live session, tmux's own screen beats the byte tail: the
|
||||||
// tail starts mid-stream, so a full-screen program (an agent TUI)
|
// tail starts mid-stream, so a full-screen program (an agent TUI)
|
||||||
// replayed from it renders as garbage until something forces a
|
// replayed from it renders as garbage until something forces a
|
||||||
// repaint. A dead session has no pane left to capture, so it
|
// repaint. A dead session has no pane left to capture, so it
|
||||||
// keeps the byte tail.
|
// keeps the byte tail.
|
||||||
let alive = session.status == SessionStatus::Running;
|
let alive = rec.session.status == SessionStatus::Running;
|
||||||
// The capture shells out to tmux while the replay lock is held,
|
// The capture shells out to tmux while the replay lock is held,
|
||||||
// which parks the reader for the length of one tmux round-trip.
|
// which parks the reader for the length of one tmux round-trip.
|
||||||
// That only happens on attach, and the alternative is a seam in
|
// That only happens on attach, and the alternative is a seam in
|
||||||
// the stream.
|
// the stream.
|
||||||
let result = bridge.subscribe_with_snapshot(|| {
|
let result = rec.bridge.subscribe_with_snapshot(|| {
|
||||||
if !alive {
|
if !alive {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -512,7 +499,7 @@ impl SessionManager {
|
|||||||
fifo_path,
|
fifo_path,
|
||||||
worktree_path,
|
worktree_path,
|
||||||
worktree_base_repo,
|
worktree_base_repo,
|
||||||
bridge: Some(bridge),
|
bridge,
|
||||||
reported_activity: None,
|
reported_activity: None,
|
||||||
settled: false,
|
settled: false,
|
||||||
#[cfg(feature = "haiku-autoname")]
|
#[cfg(feature = "haiku-autoname")]
|
||||||
@@ -720,7 +707,7 @@ impl SessionManager {
|
|||||||
// directory blocks that branch forever (#54 review).
|
// directory blocks that branch forever (#54 review).
|
||||||
worktree_path: session.worktree.as_ref().map(|w| w.path.clone()),
|
worktree_path: session.worktree.as_ref().map(|w| w.path.clone()),
|
||||||
worktree_base_repo: session.worktree.as_ref().map(|w| w.base_repo.clone()),
|
worktree_base_repo: session.worktree.as_ref().map(|w| w.base_repo.clone()),
|
||||||
bridge: Some(bridge),
|
bridge,
|
||||||
reported_activity: None,
|
reported_activity: None,
|
||||||
settled: false,
|
settled: false,
|
||||||
#[cfg(feature = "haiku-autoname")]
|
#[cfg(feature = "haiku-autoname")]
|
||||||
@@ -735,89 +722,16 @@ impl SessionManager {
|
|||||||
.send(CatalogEvent::SessionAdded { session });
|
.send(CatalogEvent::SessionAdded { session });
|
||||||
adopted += 1;
|
adopted += 1;
|
||||||
}
|
}
|
||||||
self.register_dormant().await;
|
|
||||||
Ok(adopted)
|
Ok(adopted)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Second recovery pass (#162): register every sidecar tmux had nothing to
|
/// Forward keystrokes to a session's pane. A dead session is read-only —
|
||||||
/// adopt as a [`SessionStatus::Dormant`] session.
|
/// its pane is gone, so this is a no-op rather than a tmux failure the
|
||||||
///
|
/// client would have to interpret.
|
||||||
/// A host reboot kills the tmux server, so the first pass runs over an
|
|
||||||
/// empty list and every session would otherwise disappear from the API
|
|
||||||
/// while its sidecar sat untouched on disk. A dormant record keeps the id,
|
|
||||||
/// name, cwd, command, worktree and `created_at` — everything the sidecar
|
|
||||||
/// holds — so the operator still has a record of what was running. It has
|
|
||||||
/// no tmux session and no [`PtyBridge`] behind it: it cannot be attached
|
|
||||||
/// to, cannot be typed into, and is skipped by both watcher passes. The
|
|
||||||
/// only thing left to do with one is dismiss it, which removes the sidecar
|
|
||||||
/// so the next recovery does not bring it back.
|
|
||||||
///
|
|
||||||
/// Activity is not carried over: whatever the session was doing, it is not
|
|
||||||
/// doing it now. The rest of the sidecar is left as written.
|
|
||||||
async fn register_dormant(&self) {
|
|
||||||
let stored = match self.inner.sidecar.load_all() {
|
|
||||||
Ok(stored) => stored,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(error = ?e, "recover: reading sidecars for dormant sessions failed");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let mut registered = Vec::new();
|
|
||||||
{
|
|
||||||
let mut sessions = self.inner.sessions.lock().await;
|
|
||||||
for mut session in stored {
|
|
||||||
if sessions.contains_key(&session.id) {
|
|
||||||
// Adopted live by the first pass; its sidecar is on disk too.
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let id = session.id;
|
|
||||||
session.status = SessionStatus::Dormant;
|
|
||||||
session.foreground = None;
|
|
||||||
session.activity = ActivityState::Idle;
|
|
||||||
session.activity_detail = None;
|
|
||||||
sessions.insert(
|
|
||||||
id,
|
|
||||||
SessionRecord {
|
|
||||||
session: session.clone(),
|
|
||||||
fifo_path: self.inner.fifo_dir.join(format!("{id}.fifo")),
|
|
||||||
// Still known, so a dismissal cleans the worktree up —
|
|
||||||
// the same reason recovery restores it for a live
|
|
||||||
// session (#54 review).
|
|
||||||
worktree_path: session.worktree.as_ref().map(|w| w.path.clone()),
|
|
||||||
worktree_base_repo: session.worktree.as_ref().map(|w| w.base_repo.clone()),
|
|
||||||
bridge: None,
|
|
||||||
reported_activity: None,
|
|
||||||
settled: false,
|
|
||||||
#[cfg(feature = "haiku-autoname")]
|
|
||||||
naming: false,
|
|
||||||
focused_since: None,
|
|
||||||
awaiting_suppressed: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
registered.push(session);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !registered.is_empty() {
|
|
||||||
tracing::info!(
|
|
||||||
dormant = registered.len(),
|
|
||||||
"registered sessions with no live tmux as dormant"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for session in registered {
|
|
||||||
let _ = self
|
|
||||||
.inner
|
|
||||||
.events
|
|
||||||
.send(CatalogEvent::SessionAdded { session });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Forward keystrokes to a session's pane. A dead or dormant session is
|
|
||||||
/// read-only — its pane is gone, so this is a no-op rather than a tmux
|
|
||||||
/// failure the client would have to interpret.
|
|
||||||
pub async fn send_keys(&self, id: Uuid, bytes: &[u8]) -> Result<(), SessionError> {
|
pub async fn send_keys(&self, id: Uuid, bytes: &[u8]) -> Result<(), SessionError> {
|
||||||
match self.status(id).await {
|
match self.status(id).await {
|
||||||
None => return Err(SessionError::NotFound(id)),
|
None => return Err(SessionError::NotFound(id)),
|
||||||
Some(SessionStatus::Dead | SessionStatus::Dormant) => return Ok(()),
|
Some(SessionStatus::Dead) => return Ok(()),
|
||||||
Some(SessionStatus::Running) => {}
|
Some(SessionStatus::Running) => {}
|
||||||
}
|
}
|
||||||
let tmux = self.inner.tmux.clone();
|
let tmux = self.inner.tmux.clone();
|
||||||
@@ -830,15 +744,13 @@ impl SessionManager {
|
|||||||
/// Type `text` into a session's pane, with an Enter after it when
|
/// Type `text` into a session's pane, with an Enter after it when
|
||||||
/// `submit` is set.
|
/// `submit` is set.
|
||||||
///
|
///
|
||||||
/// Unlike [`Self::send_keys`], a dead or dormant session is an error: the
|
/// Unlike [`Self::send_keys`], a dead session is an error: the caller
|
||||||
/// caller asked for text to land somewhere, and a silent success would let
|
/// asked for text to land somewhere, and a silent success would let a
|
||||||
/// a script believe an agent had been told something it never heard.
|
/// script believe an agent had been told something it never heard.
|
||||||
pub async fn send_input(&self, id: Uuid, text: &str, submit: bool) -> Result<(), SessionError> {
|
pub async fn send_input(&self, id: Uuid, text: &str, submit: bool) -> Result<(), SessionError> {
|
||||||
match self.status(id).await {
|
match self.status(id).await {
|
||||||
None => return Err(SessionError::NotFound(id)),
|
None => return Err(SessionError::NotFound(id)),
|
||||||
Some(SessionStatus::Dead | SessionStatus::Dormant) => {
|
Some(SessionStatus::Dead) => return Err(SessionError::NotRunning(id)),
|
||||||
return Err(SessionError::NotRunning(id))
|
|
||||||
}
|
|
||||||
Some(SessionStatus::Running) => {}
|
Some(SessionStatus::Running) => {}
|
||||||
}
|
}
|
||||||
let tmux = self.inner.tmux.clone();
|
let tmux = self.inner.tmux.clone();
|
||||||
@@ -856,12 +768,12 @@ impl SessionManager {
|
|||||||
Ok(())
|
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`].
|
/// [`Self::send_keys`].
|
||||||
pub async fn resize(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), SessionError> {
|
pub async fn resize(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), SessionError> {
|
||||||
match self.status(id).await {
|
match self.status(id).await {
|
||||||
None => return Err(SessionError::NotFound(id)),
|
None => return Err(SessionError::NotFound(id)),
|
||||||
Some(SessionStatus::Dead | SessionStatus::Dormant) => return Ok(()),
|
Some(SessionStatus::Dead) => return Ok(()),
|
||||||
Some(SessionStatus::Running) => {}
|
Some(SessionStatus::Running) => {}
|
||||||
}
|
}
|
||||||
let tmux = self.inner.tmux.clone();
|
let tmux = self.inner.tmux.clone();
|
||||||
@@ -1262,11 +1174,7 @@ async fn run_activity_pass(inner: &Arc<Inner>) {
|
|||||||
rec.session.activity_detail = None;
|
rec.session.activity_detail = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Only `Running` sessions reach this pass, and every one of them
|
let idle = rec.bridge.idle_for();
|
||||||
// has a bridge; a dormant session has neither pane nor bridge.
|
|
||||||
let Some(idle) = rec.bridge.as_ref().map(|b| b.idle_for()) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
// #105: while a report is in force it supplies the state and
|
// #105: while a report is in force it supplies the state and
|
||||||
// nothing here may overwrite it; only an unreported session is
|
// nothing here may overwrite it; only an unreported session is
|
||||||
// decided by inference.
|
// decided by inference.
|
||||||
@@ -1384,10 +1292,8 @@ async fn run_blocked_work_pass(inner: &Arc<Inner>) {
|
|||||||
/// alone and stays attached to the dead record for a later forced dismissal
|
/// alone and stays attached to the dead record for a later forced dismissal
|
||||||
/// (#81), which leaves it on disk too.
|
/// (#81), which leaves it on disk too.
|
||||||
///
|
///
|
||||||
/// A dead session does not come back as dead across a daemon restart: its
|
/// Dead sessions do not survive a daemon restart: [`SessionManager::recover`]
|
||||||
/// sidecar survives, so [`SessionManager::recover`]'s second pass registers it
|
/// adopts only what tmux still holds.
|
||||||
/// as [`SessionStatus::Dormant`] (#162) — accurate either way, since neither
|
|
||||||
/// has a pane behind it.
|
|
||||||
async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
|
async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
|
||||||
let flipped = {
|
let flipped = {
|
||||||
let mut sessions = inner.sessions.lock().await;
|
let mut sessions = inner.sessions.lock().await;
|
||||||
@@ -1396,9 +1302,7 @@ async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
|
|||||||
record.session.status = SessionStatus::Dead;
|
record.session.status = SessionStatus::Dead;
|
||||||
// Release anyone attached; the scrollback survives for a later
|
// Release anyone attached; the scrollback survives for a later
|
||||||
// attach.
|
// attach.
|
||||||
if let Some(bridge) = record.bridge.as_mut() {
|
record.bridge.seal();
|
||||||
bridge.seal();
|
|
||||||
}
|
|
||||||
let session = record.session.clone();
|
let session = record.session.clone();
|
||||||
let worktree = record
|
let worktree = record
|
||||||
.worktree_base_repo
|
.worktree_base_repo
|
||||||
|
|||||||
@@ -111,16 +111,33 @@ impl WorktreeManager {
|
|||||||
// Clear registrations whose directory is gone first, so a
|
// Clear registrations whose directory is gone first, so a
|
||||||
// previously-leaked entry doesn't push this branch to a suffix.
|
// previously-leaked entry doesn't push this branch to a suffix.
|
||||||
prune(base_repo)?;
|
prune(base_repo)?;
|
||||||
// Anything occupying the target — a tree we left behind, one the
|
let (branch, path) = if create_branch {
|
||||||
// operator made, an unrelated directory — is sidestepped with a
|
// Worktrees are commonly abandoned without deleting their branch,
|
||||||
// numeric suffix on the directory; the branch stays whatever was
|
// so a random handle can collide with one of its own past runs.
|
||||||
// requested (#77). The daemon never adopts what it finds.
|
// Unlike the `add_existing` arm below, the suffix goes on the
|
||||||
let mut path = self.target_path(base_repo, branch);
|
// branch name itself — the path is derived from it anyway.
|
||||||
let mut n = 1;
|
let mut candidate = branch.to_string();
|
||||||
while path.symlink_metadata().is_ok() {
|
let mut path = self.target_path(base_repo, &candidate);
|
||||||
n += 1;
|
let mut n = 1;
|
||||||
path = self.target_path_suffixed(base_repo, branch, n);
|
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
|
// The parent (`<repo>/worktrees` or the override root) may not exist
|
||||||
// yet; the target itself must not.
|
// yet; the target itself must not.
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
@@ -129,10 +146,10 @@ impl WorktreeManager {
|
|||||||
let mut cmd = Command::new("git");
|
let mut cmd = Command::new("git");
|
||||||
cmd.arg("-C").arg(base_repo).args(["worktree", "add"]);
|
cmd.arg("-C").arg(base_repo).args(["worktree", "add"]);
|
||||||
if create_branch {
|
if create_branch {
|
||||||
cmd.arg("-b").arg(branch).arg(&path);
|
cmd.arg("-b").arg(&branch).arg(&path);
|
||||||
} else {
|
} else {
|
||||||
// Existing branch: `worktree add <path> <branch>` with no `-b`.
|
// Existing branch: `worktree add <path> <branch>` with no `-b`.
|
||||||
cmd.arg(&path).arg(branch);
|
cmd.arg(&path).arg(&branch);
|
||||||
}
|
}
|
||||||
let out = cmd.output()?;
|
let out = cmd.output()?;
|
||||||
check(out)?;
|
check(out)?;
|
||||||
@@ -266,6 +283,17 @@ fn branch_relpath(branch: &str) -> Option<PathBuf> {
|
|||||||
Some(candidate.to_path_buf())
|
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
|
/// `git -C <base_repo> worktree prune` — drops registrations whose directory
|
||||||
/// no longer exists.
|
/// no longer exists.
|
||||||
fn prune(base_repo: &Path) -> Result<(), WorktreeError> {
|
fn prune(base_repo: &Path) -> Result<(), WorktreeError> {
|
||||||
|
|||||||
@@ -140,140 +140,6 @@ async fn recover_adopts_existing_tmux_sessions() {
|
|||||||
assert_eq!(snap[0].command, "sleep 30");
|
assert_eq!(snap[0].command, "sleep 30");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// #162: a host reboot kills the tmux server, so recovery finds nothing to
|
|
||||||
/// adopt while every sidecar is still on disk. Those sessions come back as
|
|
||||||
/// `Dormant` — a record of what was running — rather than vanishing from the
|
|
||||||
/// API and leaving the operator with nothing.
|
|
||||||
#[tokio::test]
|
|
||||||
async fn recover_registers_orphaned_sidecars_as_dormant() {
|
|
||||||
let fx = DaemonFixture::new();
|
|
||||||
let cwd = TempDir::new().unwrap();
|
|
||||||
let mut metadata = HashMap::new();
|
|
||||||
metadata.insert(
|
|
||||||
maestro_protocol::NAME_KEY.to_string(),
|
|
||||||
serde_json::Value::String("after-reboot".into()),
|
|
||||||
);
|
|
||||||
|
|
||||||
let s = fx
|
|
||||||
.manager
|
|
||||||
.create(CreateSessionRequest {
|
|
||||||
cwd: cwd.path().to_path_buf(),
|
|
||||||
command: "sleep 30".into(),
|
|
||||||
env: HashMap::new(),
|
|
||||||
metadata,
|
|
||||||
worktree: None,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.expect("create");
|
|
||||||
|
|
||||||
// The reboot: the tmux server is gone, the sidecar is untouched.
|
|
||||||
fx.tmux.tmux.kill_server().expect("kill tmux server");
|
|
||||||
|
|
||||||
let revived = revive(&fx);
|
|
||||||
let adopted = revived.recover().await.expect("recover");
|
|
||||||
assert_eq!(adopted, 0, "no live tmux session left to adopt");
|
|
||||||
|
|
||||||
let snap = revived.snapshot().await;
|
|
||||||
assert_eq!(snap.len(), 1, "the sidecar's session is still listed");
|
|
||||||
assert_eq!(snap[0].id, s.id);
|
|
||||||
assert_eq!(snap[0].status, SessionStatus::Dormant);
|
|
||||||
assert_eq!(snap[0].command, "sleep 30");
|
|
||||||
assert_eq!(snap[0].cwd, s.cwd);
|
|
||||||
assert_eq!(snap[0].created_at, s.created_at);
|
|
||||||
assert_eq!(
|
|
||||||
snap[0]
|
|
||||||
.metadata
|
|
||||||
.get(maestro_protocol::NAME_KEY)
|
|
||||||
.and_then(|v| v.as_str()),
|
|
||||||
Some("after-reboot"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// #162: a session tmux still holds is adopted `Running` by the first pass and
|
|
||||||
/// must not be re-registered as dormant by the second — the sidecar is on disk
|
|
||||||
/// for both.
|
|
||||||
#[tokio::test]
|
|
||||||
async fn recover_does_not_mark_adopted_sessions_dormant() {
|
|
||||||
let fx = DaemonFixture::new();
|
|
||||||
let cwd = TempDir::new().unwrap();
|
|
||||||
|
|
||||||
let live = fx
|
|
||||||
.manager
|
|
||||||
.create(CreateSessionRequest {
|
|
||||||
cwd: cwd.path().to_path_buf(),
|
|
||||||
command: "sleep 30".into(),
|
|
||||||
env: HashMap::new(),
|
|
||||||
metadata: HashMap::new(),
|
|
||||||
worktree: None,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.expect("create live");
|
|
||||||
let orphan = fx
|
|
||||||
.manager
|
|
||||||
.create(CreateSessionRequest {
|
|
||||||
cwd: cwd.path().to_path_buf(),
|
|
||||||
command: "sleep 31".into(),
|
|
||||||
env: HashMap::new(),
|
|
||||||
metadata: HashMap::new(),
|
|
||||||
worktree: None,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.expect("create orphan");
|
|
||||||
// Only the second session loses its pane.
|
|
||||||
fx.tmux.tmux.kill_session(orphan.id).expect("kill session");
|
|
||||||
|
|
||||||
let revived = revive(&fx);
|
|
||||||
assert_eq!(revived.recover().await.expect("recover"), 1);
|
|
||||||
|
|
||||||
let mut snap = revived.snapshot().await;
|
|
||||||
snap.sort_by_key(|s| s.command.clone());
|
|
||||||
assert_eq!(snap.len(), 2);
|
|
||||||
assert_eq!(snap[0].id, live.id);
|
|
||||||
assert_eq!(snap[0].status, SessionStatus::Running);
|
|
||||||
assert_eq!(snap[1].id, orphan.id);
|
|
||||||
assert_eq!(snap[1].status, SessionStatus::Dormant);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// #162: dismissing a dormant session is the one thing that can be done to it
|
|
||||||
/// — no tmux call to fail, and the sidecar goes with it so the next recovery
|
|
||||||
/// does not resurrect it.
|
|
||||||
#[tokio::test]
|
|
||||||
async fn dormant_session_can_be_dismissed() {
|
|
||||||
let fx = DaemonFixture::new();
|
|
||||||
let cwd = TempDir::new().unwrap();
|
|
||||||
|
|
||||||
let s = fx
|
|
||||||
.manager
|
|
||||||
.create(CreateSessionRequest {
|
|
||||||
cwd: cwd.path().to_path_buf(),
|
|
||||||
command: "sleep 30".into(),
|
|
||||||
env: HashMap::new(),
|
|
||||||
metadata: HashMap::new(),
|
|
||||||
worktree: None,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.expect("create");
|
|
||||||
fx.tmux.tmux.kill_server().expect("kill tmux server");
|
|
||||||
|
|
||||||
let revived = revive(&fx);
|
|
||||||
revived.recover().await.expect("recover");
|
|
||||||
revived.kill(s.id, false).await.expect("dismiss");
|
|
||||||
|
|
||||||
assert!(!revived.has(s.id).await);
|
|
||||||
assert!(!fx.sidecar_dir().join(format!("{}.json", s.id)).exists());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A second `SessionManager` over the same dirs and the same tmux server —
|
|
||||||
/// what a daemon restart looks like from inside a test.
|
|
||||||
fn revive(fx: &DaemonFixture) -> maestro_daemon::SessionManager {
|
|
||||||
maestro_daemon::SessionManager::new(
|
|
||||||
Arc::new(fx.tmux.tmux.clone()),
|
|
||||||
maestro_daemon::sidecar::SidecarStore::new(fx.sidecar_dir()),
|
|
||||||
maestro_daemon::worktree::WorktreeManager::new(None),
|
|
||||||
fx.fifo_dir(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Regression for NAP-253: the very first bytes a freshly-spawned command
|
/// Regression for NAP-253: the very first bytes a freshly-spawned command
|
||||||
/// writes must reach the replay buffer. Before the fix, `pipe-pane` was a
|
/// writes must reach the replay buffer. Before the fix, `pipe-pane` was a
|
||||||
/// separate tmux invocation that ran after `new-session` + an async FIFO
|
/// separate tmux invocation that ran after `new-session` + an async FIFO
|
||||||
|
|||||||
@@ -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
|
/// #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
|
/// git has never heard of. The daemon neither adopts it nor errors — it builds
|
||||||
/// beside it and leaves every byte of it alone.
|
/// beside it and leaves every byte of it alone.
|
||||||
|
|||||||
+142
-55
@@ -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
|
//! Two halves, both shared by the `maestro hooks install` CLI subcommand and
|
||||||
//! the daemon's first-start auto-install:
|
//! the daemon's first-start auto-install:
|
||||||
@@ -6,28 +6,40 @@
|
|||||||
//! * [`HOOK_SCRIPT`] — the hook itself, written to disk at install time. It
|
//! * [`HOOK_SCRIPT`] — the hook itself, written to disk at install time. It
|
||||||
//! posts an [`ActivityState`](maestro-protocol) to
|
//! posts an [`ActivityState`](maestro-protocol) to
|
||||||
//! `$MAESTRO_URL/api/sessions/$MAESTRO_SESSION_ID/activity` (#105). The same
|
//! `$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
|
//! 0 and silently when either #103 env var is unset, and it never lets a
|
||||||
//! daemon failure escape (short curl timeout, status ignored).
|
//! daemon failure escape (short curl timeout, status ignored).
|
||||||
//! * [`install`] — idempotent registration of that script in
|
//! * [`install`] — idempotent registration of that script in one [`Target`]'s
|
||||||
//! `~/.claude/settings.json`. Only entries maestro itself wrote (identified
|
//! hook file. Only entries maestro itself wrote (identified by
|
||||||
//! by [`HOOK_MARKER`]) are touched; every other hook and every other field
|
//! [`HOOK_MARKER`]) are touched; every other hook and every other field in
|
||||||
//! in the file is preserved verbatim.
|
//! 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;
|
use std::path::Path;
|
||||||
|
|
||||||
/// Marker substring every maestro-installed hook command carries. The
|
/// Marker substring every maestro-installed hook command carries. The
|
||||||
/// installer rewrites or removes exactly these entries — nothing else in the
|
/// installer rewrites or removes exactly these entries — nothing else in the
|
||||||
/// user's settings file.
|
/// 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";
|
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";
|
pub const SCRIPT_FILE_NAME: &str = "maestro-claude-hook.sh";
|
||||||
|
|
||||||
/// Hook events installed only with the `haiku-autoname` feature (#131).
|
/// Hook events installed only with the `haiku-autoname` feature (#131), for
|
||||||
/// `UserPromptSubmit` carries the prompt the user just submitted; the script
|
/// every [`Target`] — Claude Code and Codex both spell the event
|
||||||
/// forwards the whole payload to `/api/sessions/:id/prompt`, which names a
|
/// `UserPromptSubmit` and both put the submitted text in the payload's
|
||||||
/// still-provisionally-named session after its first one.
|
/// `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")]
|
#[cfg(feature = "haiku-autoname")]
|
||||||
pub const PROMPT_EVENTS: [(&str, &str); 1] = [("UserPromptSubmit", "Prompt")];
|
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
|
/// 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
|
/// failure signal in `Stop` or `SessionEnd` payloads, so those never report
|
||||||
/// `Error`.
|
/// `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 starting: the agent is demonstrably producing work, even if the
|
||||||
// tool itself runs for minutes.
|
// tool itself runs for minutes.
|
||||||
("PreToolUse", "Active"),
|
("PreToolUse", "Active"),
|
||||||
@@ -58,17 +70,86 @@ pub const HOOK_EVENTS: [(&str, &str); 5] = [
|
|||||||
("StopFailure", "Error"),
|
("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.
|
/// The shipped hook script. POSIX sh so any `/bin/sh` works.
|
||||||
pub const HOOK_SCRIPT: &str = r#"#!/bin/sh
|
pub const HOOK_SCRIPT: &str = r#"#!/bin/sh
|
||||||
# Installed by maestro (#106): report Claude Code activity to its daemon.
|
# Installed by maestro (#106): report agent activity to its daemon.
|
||||||
# Exits silently unless running inside a session maestro spawned (#103 env).
|
# 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:
|
# Event -> ActivityState mapping:
|
||||||
# PreToolUse -> Active (tool started; agent is working)
|
# PreToolUse -> Active (tool started; agent is working)
|
||||||
# PostToolUse -> Active (tool finished)
|
# PostToolUse -> Active (tool finished)
|
||||||
# Notification -> AwaitingInput (permission / idle notice)
|
# Notification -> AwaitingInput (claude code: permission / idle)
|
||||||
# Stop -> AwaitingInput (turn ended; waiting on the user)
|
# PermissionRequest -> AwaitingInput (codex: waiting on an approval)
|
||||||
# StopFailure -> Error (turn ended on an API error)
|
# Stop -> AwaitingInput (turn ended; waiting on the user)
|
||||||
# Prompt -> the payload is forwarded to the prompt route (#131)
|
# StopFailure -> Error (claude code: turn ended on an error)
|
||||||
|
# Prompt -> the payload is forwarded to the prompt route (#131)
|
||||||
state="$1"
|
state="$1"
|
||||||
[ -n "$MAESTRO_SESSION_ID" ] || exit 0
|
[ -n "$MAESTRO_SESSION_ID" ] || exit 0
|
||||||
[ -n "$MAESTRO_URL" ] || exit 0
|
[ -n "$MAESTRO_URL" ] || exit 0
|
||||||
@@ -115,34 +196,29 @@ pub fn script_path(home: &Path) -> std::path::PathBuf {
|
|||||||
home.join(".local/share/maestro").join(SCRIPT_FILE_NAME)
|
home.join(".local/share/maestro").join(SCRIPT_FILE_NAME)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The user-level Claude Code settings file for a given home directory.
|
/// Whether the file at `settings_path` already carries a maestro hook
|
||||||
pub fn settings_path(home: &Path) -> std::path::PathBuf {
|
/// (#106). Presence only: an install from an older release answers `true`
|
||||||
home.join(".claude/settings.json")
|
/// even when it is missing events this build ships — see [`is_current`].
|
||||||
}
|
|
||||||
|
|
||||||
/// 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`].
|
|
||||||
pub fn is_installed(settings_path: &Path) -> bool {
|
pub fn is_installed(settings_path: &Path) -> bool {
|
||||||
settings_json(settings_path)
|
settings_json(settings_path)
|
||||||
.map(|root| find_maestro_commands(&root).next().is_some())
|
.map(|root| find_maestro_commands(&root).next().is_some())
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether what is on disk is what this build ships: the script at
|
/// Whether what is on disk is what this build ships for `target`: the script
|
||||||
/// `script_path` byte-for-byte, and exactly one settings entry per event in
|
/// at `script_path` byte-for-byte, and exactly one settings entry per one of
|
||||||
/// [`HOOK_EVENTS`] and [`PROMPT_EVENTS`], pointing at that script.
|
/// the target's [`Target::events`], pointing at that script.
|
||||||
///
|
///
|
||||||
/// This is the upgrade question. A maestro that gains an event — #131 added
|
/// This is the upgrade question, asked per [`Target`]. A maestro that gains
|
||||||
/// `UserPromptSubmit` long after #106 had installed itself — leaves every
|
/// an event — #131 added `UserPromptSubmit` long after #106 had installed
|
||||||
/// existing user with a present but incomplete install, which
|
/// itself — leaves every existing user with a present but incomplete
|
||||||
/// [`is_installed`] cannot tell apart from a current one.
|
/// install, which [`is_installed`] cannot tell apart from a current one.
|
||||||
pub fn is_current(settings_path: &Path, script_path: &Path) -> bool {
|
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) {
|
if std::fs::read_to_string(script_path).ok().as_deref() != Some(HOOK_SCRIPT) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
settings_json(settings_path)
|
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)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,14 +249,13 @@ fn maestro_entries(root: &serde_json::Value) -> Vec<(String, String)> {
|
|||||||
found
|
found
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What [`install`] writes for `script_path`, in the same shape.
|
/// What [`install`] writes for `target` and `script_path`, in the same shape.
|
||||||
fn expected_entries(script_path: &Path) -> Vec<(String, String)> {
|
fn expected_entries(target: Target, script_path: &Path) -> Vec<(String, String)> {
|
||||||
let mut want: Vec<(String, String)> = HOOK_EVENTS
|
let mut want: Vec<(String, String)> = target
|
||||||
.iter()
|
.events()
|
||||||
.chain(PROMPT_EVENTS.iter())
|
|
||||||
.map(|(event, state)| {
|
.map(|(event, state)| {
|
||||||
(
|
(
|
||||||
(*event).to_string(),
|
event.to_string(),
|
||||||
format!("{} {state}", script_path.display()),
|
format!("{} {state}", script_path.display()),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -207,28 +282,40 @@ pub fn write_script(path: &Path) -> Result<(), HooksError> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Install the full hook setup for `home`: write the script under
|
/// Install the full hook setup for one target under `home`: write the script
|
||||||
/// `.local/share/maestro/` and register it in `.claude/settings.json`.
|
/// under `.local/share/maestro/` and register it in the target's hook file.
|
||||||
/// Idempotent; returns whether anything changed on disk.
|
/// 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 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
|
// A script left over from an older release counts as a change even when
|
||||||
// the settings entries pointing at it are already right.
|
// 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)?;
|
write_script(&script)?;
|
||||||
install(&settings, &script)?;
|
install(target, &settings, &script)?;
|
||||||
Ok(changed)
|
Ok(changed)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Register the hook command for every event in [`HOOK_EVENTS`] in the
|
/// Install for every [`Target::ALL`], reporting each one separately. One
|
||||||
/// settings file at `settings_path`. Idempotent: existing maestro entries are
|
/// target failing — an unwritable file, unparseable JSON — never stops the
|
||||||
/// updated in place wherever they sit, stale ones removed, duplicates folded;
|
/// others: an agent whose hook could not be written is one agent without
|
||||||
/// every non-maestro entry and every unrelated top-level field survives.
|
/// 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
|
/// Never inspects or writes anywhere but `settings_path` — callers pass the
|
||||||
/// user's real home or a temp one in tests, never a repository.
|
/// 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)?;
|
let mut root = settings_json(settings_path)?;
|
||||||
|
|
||||||
// Strip every maestro entry wherever it currently sits, so an older
|
// Strip every maestro entry wherever it currently sits, so an older
|
||||||
@@ -236,7 +323,7 @@ pub fn install(settings_path: &Path, script_path: &Path) -> Result<(), HooksErro
|
|||||||
strip_maestro_entries(&mut root);
|
strip_maestro_entries(&mut root);
|
||||||
|
|
||||||
let hooks = ensure_object(&mut root, "hooks");
|
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 groups = ensure_array(hooks, event);
|
||||||
let mut group = serde_json::Map::new();
|
let mut group = serde_json::Map::new();
|
||||||
group.insert(
|
group.insert(
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
//! Tests for the shipped hook script (run as a real `/bin/sh` subprocess)
|
//! Tests for the shipped hook script (run as a real `/bin/sh` subprocess)
|
||||||
//! and the settings-file installer. Everything happens under a temp `$HOME`;
|
//! 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::io::{Read, Write};
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// How many commands one install writes: every activity event, plus the
|
/// How many commands one install writes for a target: its activity events,
|
||||||
/// prompt event when `haiku-autoname` is on (#131).
|
/// plus the prompt event when `haiku-autoname` is on (#131).
|
||||||
const INSTALLED_EVENTS: usize = HOOK_EVENTS.len() + PROMPT_EVENTS.len();
|
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 {
|
fn sh(script: &str, args: &[&str], envs: &[(&str, &str)], payload: &[u8]) -> std::process::Output {
|
||||||
let mut cmd = Command::new("/bin/sh");
|
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.
|
/// exit 0, no output, stdin drained.
|
||||||
#[test]
|
#[test]
|
||||||
fn script_exits_silently_without_maestro_env() {
|
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"}"#);
|
let out = sh(HOOK_SCRIPT, &[event], &[], br#"{"session_id":"x"}"#);
|
||||||
assert!(out.status.success(), "{event:?}: {:?}", out.stderr);
|
assert!(out.status.success(), "{event:?}: {:?}", out.stderr);
|
||||||
assert!(out.stdout.is_empty(), "{event:?}: {:?}", out.stdout);
|
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.
|
/// Every event maps to the state the contract specifies.
|
||||||
#[test]
|
#[test]
|
||||||
fn event_state_mapping_matches_the_contract() {
|
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["PreToolUse"], "Active");
|
||||||
assert_eq!(map["PostToolUse"], "Active");
|
assert_eq!(map["PostToolUse"], "Active");
|
||||||
assert_eq!(map["Notification"], "AwaitingInput");
|
assert_eq!(map["Notification"], "AwaitingInput");
|
||||||
@@ -203,10 +212,10 @@ fn fresh_install_registers_every_event() {
|
|||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let home = dir.path();
|
let home = dir.path();
|
||||||
write_script(&script_path(home)).unwrap();
|
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));
|
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");
|
let joined = cmds.join("\n");
|
||||||
assert_eq!(joined.matches("Active").count(), 2);
|
assert_eq!(joined.matches("Active").count(), 2);
|
||||||
assert!(joined.contains("Error"));
|
assert!(joined.contains("Error"));
|
||||||
@@ -221,15 +230,15 @@ fn fresh_install_registers_every_event() {
|
|||||||
fn install_is_idempotent() {
|
fn install_is_idempotent() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let home = dir.path();
|
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();
|
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();
|
let twice = std::fs::read_to_string(settings_path(home)).unwrap();
|
||||||
assert_eq!(once, twice);
|
assert_eq!(once, twice);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
installed_commands(&settings_path(home)).len(),
|
installed_commands(&settings_path(home)).len(),
|
||||||
INSTALLED_EVENTS
|
installed_events(Target::ClaudeCode)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,12 +266,12 @@ fn install_updates_an_older_maestro_hook_in_place() {
|
|||||||
.path()
|
.path()
|
||||||
.join(".local/share/maestro")
|
.join(".local/share/maestro")
|
||||||
.join(SCRIPT_FILE_NAME);
|
.join(SCRIPT_FILE_NAME);
|
||||||
install(&settings, &new_script).unwrap();
|
install(Target::ClaudeCode, &settings, &new_script).unwrap();
|
||||||
|
|
||||||
let cmds = installed_commands(&settings);
|
let cmds = installed_commands(&settings);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cmds.len(),
|
cmds.len(),
|
||||||
INSTALLED_EVENTS,
|
installed_events(Target::ClaudeCode),
|
||||||
"old entry replaced, not kept: {cmds:?}"
|
"old entry replaced, not kept: {cmds:?}"
|
||||||
);
|
);
|
||||||
assert!(!cmds.iter().any(|c| c == old_script));
|
assert!(!cmds.iter().any(|c| c == old_script));
|
||||||
@@ -298,7 +307,7 @@ fn install_preserves_foreign_hooks_and_removes_stale_maestro_ones() {
|
|||||||
.path()
|
.path()
|
||||||
.join(".local/share/maestro")
|
.join(".local/share/maestro")
|
||||||
.join(SCRIPT_FILE_NAME);
|
.join(SCRIPT_FILE_NAME);
|
||||||
install(&settings, &new_script).unwrap();
|
install(Target::ClaudeCode, &settings, &new_script).unwrap();
|
||||||
|
|
||||||
let root: serde_json::Value =
|
let root: serde_json::Value =
|
||||||
serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
|
serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
|
||||||
@@ -311,7 +320,7 @@ fn install_preserves_foreign_hooks_and_removes_stale_maestro_ones() {
|
|||||||
let cmds = installed_commands(&settings);
|
let cmds = installed_commands(&settings);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cmds.iter().filter(|c| c.contains(HOOK_MARKER)).count(),
|
cmds.iter().filter(|c| c.contains(HOOK_MARKER)).count(),
|
||||||
INSTALLED_EVENTS,
|
installed_events(Target::ClaudeCode),
|
||||||
"{cmds:?}"
|
"{cmds:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -321,9 +330,15 @@ fn install_preserves_foreign_hooks_and_removes_stale_maestro_ones() {
|
|||||||
fn is_installed_tracks_reality() {
|
fn is_installed_tracks_reality() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let home = dir.path();
|
let home = dir.path();
|
||||||
assert!(!is_installed(&settings_path(home)));
|
for target in Target::ALL {
|
||||||
install_for_home(home).unwrap();
|
assert!(!is_installed(&target.settings_path(home)));
|
||||||
assert!(is_installed(&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.
|
/// The full home-level install leaves the script executable.
|
||||||
@@ -331,7 +346,9 @@ fn is_installed_tracks_reality() {
|
|||||||
fn install_for_home_writes_an_executable_script() {
|
fn install_for_home_writes_an_executable_script() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let home = dir.path();
|
let home = dir.path();
|
||||||
install_for_home(home).unwrap();
|
for (_, result) in install_for_home(home) {
|
||||||
|
result.unwrap();
|
||||||
|
}
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
@@ -350,7 +367,7 @@ fn install_for_home_writes_an_executable_script() {
|
|||||||
fn install_registers_the_prompt_event() {
|
fn install_registers_the_prompt_event() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let home = dir.path();
|
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 =
|
let root: serde_json::Value =
|
||||||
serde_json::from_str(&std::fs::read_to_string(settings_path(home)).unwrap()).unwrap();
|
serde_json::from_str(&std::fs::read_to_string(settings_path(home)).unwrap()).unwrap();
|
||||||
@@ -367,7 +384,7 @@ fn install_registers_the_prompt_event() {
|
|||||||
fn install_registers_no_prompt_event_without_the_feature() {
|
fn install_registers_no_prompt_event_without_the_feature() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let home = dir.path();
|
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 =
|
let root: serde_json::Value =
|
||||||
serde_json::from_str(&std::fs::read_to_string(settings_path(home)).unwrap()).unwrap();
|
serde_json::from_str(&std::fs::read_to_string(settings_path(home)).unwrap()).unwrap();
|
||||||
@@ -436,8 +453,12 @@ fn the_script_posts_a_prompt_payload_unchanged() {
|
|||||||
fn is_current_rejects_an_install_missing_an_event() {
|
fn is_current_rejects_an_install_missing_an_event() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let home = dir.path();
|
let home = dir.path();
|
||||||
install_for_home(home).unwrap();
|
install_target(Target::ClaudeCode, home).unwrap();
|
||||||
assert!(is_current(&settings_path(home), &script_path(home)));
|
assert!(is_current(
|
||||||
|
Target::ClaudeCode,
|
||||||
|
&settings_path(home),
|
||||||
|
&script_path(home)
|
||||||
|
));
|
||||||
|
|
||||||
// Drop one event, as an older install would have left the file.
|
// Drop one event, as an older install would have left the file.
|
||||||
let mut root: serde_json::Value =
|
let mut root: serde_json::Value =
|
||||||
@@ -450,7 +471,11 @@ fn is_current_rejects_an_install_missing_an_event() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert!(is_installed(&settings_path(home)));
|
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
|
/// A settings file that is complete but points at a script from an older
|
||||||
@@ -459,9 +484,13 @@ fn is_current_rejects_an_install_missing_an_event() {
|
|||||||
fn is_current_rejects_a_stale_script() {
|
fn is_current_rejects_a_stale_script() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let home = dir.path();
|
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();
|
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.
|
/// Nothing installed at all is not current.
|
||||||
@@ -469,18 +498,93 @@ fn is_current_rejects_a_stale_script() {
|
|||||||
fn is_current_rejects_a_missing_install() {
|
fn is_current_rejects_a_missing_install() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let home = dir.path();
|
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.
|
/// settings entries pointing at it already read correctly.
|
||||||
#[test]
|
#[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 dir = tempfile::tempdir().unwrap();
|
||||||
let home = dir.path();
|
let home = dir.path();
|
||||||
assert!(install_for_home(home).unwrap());
|
assert!(install_target(Target::ClaudeCode, home).unwrap());
|
||||||
assert!(!install_for_home(home).unwrap());
|
assert!(!install_target(Target::ClaudeCode, home).unwrap());
|
||||||
|
|
||||||
std::fs::write(script_path(home), "#!/bin/sh\nexit 0\n").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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,38 +123,10 @@ fn epoch() -> DateTime<Utc> {
|
|||||||
DateTime::from_timestamp(0, 0).expect("unix epoch is a valid timestamp")
|
DateTime::from_timestamp(0, 0).expect("unix epoch is a valid timestamp")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lifecycle of a session as the daemon knows it.
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
///
|
|
||||||
/// Deserializes leniently, like [`ActivityState`] (#162): an unknown spelling
|
|
||||||
/// from a newer daemon reads as [`SessionStatus::Dead`] instead of failing the
|
|
||||||
/// whole `Session`. `Dead` is the safe degrade — an unrecognised session is
|
|
||||||
/// one this client must not treat as having a live pane to type into.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
|
||||||
pub enum SessionStatus {
|
pub enum SessionStatus {
|
||||||
Running,
|
Running,
|
||||||
Dead,
|
Dead,
|
||||||
/// Known from its sidecar, with no tmux session behind it (#162). The
|
|
||||||
/// daemon registers these at recovery time for every sidecar tmux had
|
|
||||||
/// nothing to adopt — after a host reboot, that is every session there
|
|
||||||
/// was. There is no pane and no PTY bridge: a dormant session is a record
|
|
||||||
/// of what was running, listed so the operator still has it, and the only
|
|
||||||
/// thing that can be done to it is dismissing it.
|
|
||||||
Dormant,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'de> Deserialize<'de> for SessionStatus {
|
|
||||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
|
||||||
where
|
|
||||||
D: serde::Deserializer<'de>,
|
|
||||||
{
|
|
||||||
let s = String::deserialize(deserializer)?;
|
|
||||||
Ok(match s.as_str() {
|
|
||||||
"Running" => Self::Running,
|
|
||||||
"Dormant" => Self::Dormant,
|
|
||||||
// Known or not, any other spelling degrades to the inert variant.
|
|
||||||
_ => Self::Dead,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inferred session activity, orthogonal to [`SessionStatus`] (NAP-340). The
|
/// Inferred session activity, orthogonal to [`SessionStatus`] (NAP-340). The
|
||||||
@@ -517,11 +489,8 @@ impl ApiError {
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct SpawnPreset {
|
pub struct SpawnPreset {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
#[serde(default)]
|
|
||||||
pub label: String,
|
pub label: String,
|
||||||
pub command: String,
|
pub command: String,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub resume_command: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build provenance of the running daemon: the commit it was compiled
|
/// Build provenance of the running daemon: the commit it was compiled
|
||||||
@@ -1090,33 +1059,6 @@ mod tests {
|
|||||||
serde_json::to_value(SessionStatus::Dead).unwrap(),
|
serde_json::to_value(SessionStatus::Dead).unwrap(),
|
||||||
serde_json::json!("Dead")
|
serde_json::json!("Dead")
|
||||||
);
|
);
|
||||||
assert_eq!(
|
|
||||||
serde_json::to_value(SessionStatus::Dormant).unwrap(),
|
|
||||||
serde_json::json!("Dormant")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// #162: a session with no tmux behind it still rides the wire whole.
|
|
||||||
#[test]
|
|
||||||
fn dormant_session_round_trips() {
|
|
||||||
let mut s = sample_session();
|
|
||||||
s.status = SessionStatus::Dormant;
|
|
||||||
assert_eq!(s, round_trip(&s));
|
|
||||||
}
|
|
||||||
|
|
||||||
// #162: a `SessionStatus` this client does not know must degrade to the
|
|
||||||
// inert variant, not fail the whole `Session` the way a derived
|
|
||||||
// `Deserialize` would.
|
|
||||||
#[test]
|
|
||||||
fn unknown_session_status_degrades_to_dead() {
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::from_value::<SessionStatus>(serde_json::json!("Hibernating")).unwrap(),
|
|
||||||
SessionStatus::Dead
|
|
||||||
);
|
|
||||||
let mut value = serde_json::to_value(sample_session()).unwrap();
|
|
||||||
value["status"] = serde_json::json!("Hibernating");
|
|
||||||
let s: Session = serde_json::from_value(value).expect("session survives unknown status");
|
|
||||||
assert_eq!(s.status, SessionStatus::Dead);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -194,9 +194,7 @@ pub enum BarOutcome {
|
|||||||
pub fn order_sessions_for_bar(sessions: &[Session]) -> Vec<&Session> {
|
pub fn order_sessions_for_bar(sessions: &[Session]) -> Vec<&Session> {
|
||||||
let mut ordered: Vec<&Session> = sessions.iter().collect();
|
let mut ordered: Vec<&Session> = sessions.iter().collect();
|
||||||
ordered.sort_by(|a, b| {
|
ordered.sort_by(|a, b| {
|
||||||
// docs/COMMAND-BAR.md: anything with no live pane — dead or dormant
|
let dead = |s: &Session| u8::from(s.status == SessionStatus::Dead);
|
||||||
// (#162) — sorts after the sessions you could actually attach to.
|
|
||||||
let dead = |s: &Session| u8::from(s.status != SessionStatus::Running);
|
|
||||||
dead(a)
|
dead(a)
|
||||||
.cmp(&dead(b))
|
.cmp(&dead(b))
|
||||||
.then_with(|| {
|
.then_with(|| {
|
||||||
@@ -321,7 +319,7 @@ impl CommandBar {
|
|||||||
.map(|s| BarEntry {
|
.map(|s| BarEntry {
|
||||||
candidate: MatchCandidate::Session(session_candidate(s, home_dir)),
|
candidate: MatchCandidate::Session(session_candidate(s, home_dir)),
|
||||||
target: BarTarget::Session(s.id),
|
target: BarTarget::Session(s.id),
|
||||||
dead: s.status != SessionStatus::Running,
|
dead: s.status == SessionStatus::Dead,
|
||||||
chords: Vec::new(),
|
chords: Vec::new(),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -891,23 +889,6 @@ mod tests {
|
|||||||
assert!(bar.rows()[1].dead);
|
assert!(bar.rows()[1].dead);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// #162: docs/COMMAND-BAR.md ranks by whether you could attach, not by
|
|
||||||
/// which way the pane went away — a dormant session sorts with the dead.
|
|
||||||
#[test]
|
|
||||||
fn dormant_sessions_sort_after_live_ones_however_recent() {
|
|
||||||
let mut dormant = named(1, "dormant");
|
|
||||||
dormant.status = SessionStatus::Dormant;
|
|
||||||
dormant.last_focused_at = Some(at(99));
|
|
||||||
let live = named(2, "live");
|
|
||||||
|
|
||||||
let bar = CommandBar::new(&[dormant, live], &[], "/home/me");
|
|
||||||
assert_eq!(
|
|
||||||
ids(bar.rows()),
|
|
||||||
vec![Uuid::from_u128(2), Uuid::from_u128(1)]
|
|
||||||
);
|
|
||||||
assert!(bar.rows()[1].dead);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn typing_ranks_through_the_matcher_and_returns_ranges() {
|
fn typing_ranks_through_the_matcher_and_returns_ranges() {
|
||||||
let mut bar = CommandBar::new(
|
let mut bar = CommandBar::new(
|
||||||
|
|||||||
@@ -1705,7 +1705,6 @@ mod tests {
|
|||||||
id: "aider".into(),
|
id: "aider".into(),
|
||||||
label: "Aider".into(),
|
label: "Aider".into(),
|
||||||
command: "aider".into(),
|
command: "aider".into(),
|
||||||
resume_command: None,
|
|
||||||
}]);
|
}]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
draft.command(),
|
draft.command(),
|
||||||
@@ -1728,13 +1727,11 @@ mod tests {
|
|||||||
id: "claude".into(),
|
id: "claude".into(),
|
||||||
label: "Claude".into(),
|
label: "Claude".into(),
|
||||||
command: "claude".into(),
|
command: "claude".into(),
|
||||||
resume_command: None,
|
|
||||||
},
|
},
|
||||||
SpawnPreset {
|
SpawnPreset {
|
||||||
id: "aider".into(),
|
id: "aider".into(),
|
||||||
label: "Aider".into(),
|
label: "Aider".into(),
|
||||||
command: "aider".into(),
|
command: "aider".into(),
|
||||||
resume_command: None,
|
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
assert_eq!(draft.command(), "aider");
|
assert_eq!(draft.command(), "aider");
|
||||||
@@ -1754,7 +1751,6 @@ mod tests {
|
|||||||
id: "claude".into(),
|
id: "claude".into(),
|
||||||
label: "Claude".into(),
|
label: "Claude".into(),
|
||||||
command: "claude".into(),
|
command: "claude".into(),
|
||||||
resume_command: None,
|
|
||||||
}]);
|
}]);
|
||||||
assert!(draft.is_custom());
|
assert!(draft.is_custom());
|
||||||
assert_eq!(draft.command(), "bash -l");
|
assert_eq!(draft.command(), "bash -l");
|
||||||
|
|||||||
@@ -183,7 +183,6 @@ mod tests {
|
|||||||
id: id.into(),
|
id: id.into(),
|
||||||
label: id.into(),
|
label: id.into(),
|
||||||
command: command.into(),
|
command: command.into(),
|
||||||
resume_command: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -268,8 +268,6 @@ impl Sidebar {
|
|||||||
fn name_row(&self, s: &Session, width: usize, focused: bool) -> Vec<Span<'static>> {
|
fn name_row(&self, s: &Session, width: usize, focused: bool) -> Vec<Span<'static>> {
|
||||||
let glyph = match (s.status, self.attached.contains(&s.id)) {
|
let glyph = match (s.status, self.attached.contains(&s.id)) {
|
||||||
(SessionStatus::Dead, _) => "✗",
|
(SessionStatus::Dead, _) => "✗",
|
||||||
// #162: known from its sidecar, nothing running behind it.
|
|
||||||
(SessionStatus::Dormant, _) => "◌",
|
|
||||||
(SessionStatus::Running, true) => "●",
|
(SessionStatus::Running, true) => "●",
|
||||||
(SessionStatus::Running, false) => "○",
|
(SessionStatus::Running, false) => "○",
|
||||||
};
|
};
|
||||||
@@ -324,7 +322,6 @@ impl Sidebar {
|
|||||||
// on line 1; the kill confirm names the specifics.
|
// on line 1; the kill confirm names the specifics.
|
||||||
let blocked = s.blocked_work.filter(|b| !b.is_clear());
|
let blocked = s.blocked_work.filter(|b| !b.is_clear());
|
||||||
let dead = s.status == SessionStatus::Dead;
|
let dead = s.status == SessionStatus::Dead;
|
||||||
let dormant = s.status == SessionStatus::Dormant;
|
|
||||||
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
|
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
|
||||||
let fill = self.card_fill(s, focused);
|
let fill = self.card_fill(s, focused);
|
||||||
let base = card_base(fill, focused);
|
let base = card_base(fill, focused);
|
||||||
@@ -364,9 +361,6 @@ impl Sidebar {
|
|||||||
if dead {
|
if dead {
|
||||||
tail.push(Span::styled(" exited", muted));
|
tail.push(Span::styled(" exited", muted));
|
||||||
}
|
}
|
||||||
if dormant {
|
|
||||||
tail.push(Span::styled(" dormant", muted));
|
|
||||||
}
|
|
||||||
// docs/SIDEBAR.md: the card names the program, never its arguments.
|
// docs/SIDEBAR.md: the card names the program, never its arguments.
|
||||||
let command = Span::styled(format!(" {}", command_program(&s.command)), muted);
|
let command = Span::styled(format!(" {}", command_program(&s.command)), muted);
|
||||||
|
|
||||||
@@ -1074,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.
|
/// docs/SIDEBAR.md: line 2 names the program, never its arguments.
|
||||||
#[test]
|
#[test]
|
||||||
fn meta_line_drops_the_commands_arguments() {
|
fn meta_line_drops_the_commands_arguments() {
|
||||||
|
|||||||
+3
-3
@@ -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.
|
`POST /api/sessions/:id/focus` when it refocuses a pane that is already open.
|
||||||
|
|
||||||
**A session that has never been focused sorts after every session that has**,
|
**A session that has never been focused sorts after every session that has**,
|
||||||
and among those, most recently created first. Sessions with no live pane —
|
and among those, most recently created first. Dead sessions sort after live
|
||||||
`Dead`, and `Dormant` (#162) — sort after live ones regardless of recency: a
|
ones regardless of recency: a session you cannot attach to is not where you
|
||||||
session you cannot attach to is not where you just were.
|
just were.
|
||||||
|
|
||||||
Under `>` an empty query lists every command in registry order. Under `~` it
|
Under `>` an empty query lists every command in registry order. Under `~` it
|
||||||
lists recent dirs, most recent first, then the project list.
|
lists recent dirs, most recent first, then the project list.
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ Web client (`--preview` and friends in `web/src/styles.css`):
|
|||||||
| The canvas | A `preview` chip, top right, and a dashed inset ring. |
|
| The canvas | A `preview` chip, top right, and a dashed inset ring. |
|
||||||
| A throwaway tab | Dashed border, italic label. |
|
| A throwaway tab | Dashed border, italic label. |
|
||||||
| The sidebar row being previewed | Filled with the preview hue, over whatever open state the row already had — but the row of the session already showing in the pane keeps its accent fill, since previewing it changes nothing on screen. |
|
| The sidebar row being previewed | Filled with the preview hue, over whatever open state the row already had — but the row of the session already showing in the pane keeps its accent fill, since previewing it changes nothing on screen. |
|
||||||
|
| The sidebar row of the session the operator picked | Keeps its accent fill for the whole preview. A preview never moves the accent; only opening another session does. |
|
||||||
| A hovered sidebar row, and the keyboard cursor on a session | A lighter wash of the same hue, because both are what previews a row. |
|
| A hovered sidebar row, and the keyboard cursor on a session | A lighter wash of the same hue, because both are what previews a row. |
|
||||||
|
|
||||||
Rows say it with fill, not with an edge — see the tier ladder in
|
Rows say it with fill, not with an edge — see the tier ladder in
|
||||||
|
|||||||
@@ -68,26 +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
|
moment a probe succeeds — there is no "retry" button, because the client is
|
||||||
already retrying.
|
already retrying.
|
||||||
|
|
||||||
## What survives, and what comes back dormant
|
|
||||||
|
|
||||||
A daemon restart leaves tmux alone, so recovery re-adopts every
|
|
||||||
`maestro-<uuid>` session tmux still holds and the operator sees no change
|
|
||||||
beyond the reconnect above.
|
|
||||||
|
|
||||||
A **host reboot** is different: it kills the tmux server, so there is nothing
|
|
||||||
to adopt while every session's sidecar is still on disk. Recovery makes a
|
|
||||||
second pass over those sidecars and registers each as a `Dormant` session
|
|
||||||
(#162) — id, name, cwd, command, worktree and `created_at` intact. A dormant
|
|
||||||
session has no pane and no PTY bridge: it cannot be attached to, typed into or
|
|
||||||
resized, and neither watcher pass touches it. It is a record of what was
|
|
||||||
running, and the only thing left to do with one is dismiss it, which removes
|
|
||||||
the sidecar so the next recovery does not bring it back.
|
|
||||||
|
|
||||||
`SessionStatus` is a wire type, so it deserializes leniently on both clients:
|
|
||||||
a spelling this build does not know reads as `Dead` rather than failing the
|
|
||||||
whole session. Clients render a dormant session as known-but-not-running —
|
|
||||||
see `docs/SIDEBAR.md` for the card and `docs/COMMAND-BAR.md` for how it ranks.
|
|
||||||
|
|
||||||
## Timing
|
## Timing
|
||||||
|
|
||||||
- **Grace window** — 1200 ms between the socket dropping and the overlay
|
- **Grace window** — 1200 ms between the socket dropping and the overlay
|
||||||
|
|||||||
+12
-13
@@ -56,7 +56,7 @@ itself is not.
|
|||||||
Line 2 reads left to right:
|
Line 2 reads left to right:
|
||||||
|
|
||||||
```
|
```
|
||||||
[<duration>] [<detail>] <command> [<diff stat> | exited | dormant]
|
[<duration>] [<detail>] <command> [<diff stat> | exited]
|
||||||
```
|
```
|
||||||
|
|
||||||
Its minimum content is therefore just the command.
|
Its minimum content is therefore just the command.
|
||||||
@@ -104,14 +104,8 @@ fragment; it no longer decides whether line 2 exists.
|
|||||||
while an agent-reported state is in force (see Precedence): when the report
|
while an agent-reported state is in force (see Precedence): when the report
|
||||||
lapses at its TTL or is replaced, the detail goes with it. The detail
|
lapses at its TTL or is replaced, the detail goes with it. The detail
|
||||||
truncates with `…` ahead of the trailing fragments — a long detail never
|
truncates with `…` ahead of the trailing fragments — a long detail never
|
||||||
pushes `<command>`, `<blocked work>`, `exited` or `dormant` off the line, and
|
pushes `<command>`, `<blocked work>`, or `exited` off the line, and neither
|
||||||
neither client ever lets it wrap.
|
client ever lets it wrap.
|
||||||
|
|
||||||
`dormant` (#162) is the counterpart of `exited` for a session the daemon knows
|
|
||||||
only from its sidecar: recovery found no tmux session behind it, so it has no
|
|
||||||
pane, no scrollback, and nothing to attach to. It is not an exit — nothing was
|
|
||||||
observed ending — so the two fragments never both show. The TUI marks the same
|
|
||||||
state on line 1 with `◌`, next to `✗` for exited and `●`/`○` for running.
|
|
||||||
|
|
||||||
`<diff stat>` is the blocked-work breakdown (#83) in diff notation, replacing
|
`<diff stat>` is the blocked-work breakdown (#83) in diff notation, replacing
|
||||||
the spelled-out `⚠ 3 uncommitted files, 2 unpushed commits`. It renders only
|
the spelled-out `⚠ 3 uncommitted files, 2 unpushed commits`. It renders only
|
||||||
@@ -215,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
|
highlighted **folder** row — a folder previews nothing, so it has no fill to
|
||||||
step up to.
|
step up to.
|
||||||
|
|
||||||
The duration text inherits its state's colour; the exited, 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 use the surface's `muted` role, as do the diff stat's `~`, `?` and
|
||||||
`↑` fragments. Only `+` and `-` step outside the surface's muted role, and they
|
`↑` fragments. Only `+` and `-` step outside the surface's muted role, and they
|
||||||
take the `active` and `error` roles of the same surface.
|
take the `active` and `error` roles of the same surface.
|
||||||
@@ -256,6 +250,10 @@ Two rulings specific to a reported `Error` (#114):
|
|||||||
process changes do not clear it — only the agent itself may withdraw what
|
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
|
the agent itself reported. Re-reporting `Error` extends the TTL rather than
|
||||||
stacking reports.
|
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
|
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)
|
fail rendering: treat it as `Idle` (neutral/dim, line-2 rules apply as usual)
|
||||||
@@ -309,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
|
reachable by clicking a folder header — still steps to its neighbour rather
|
||||||
than jumping to the end of the list.
|
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
|
The web client needs a chord to reach the list at all (leader-`h` — `Ctrl+A h`
|
||||||
same chord back): an attached terminal owns every plain keystroke. The TUI uses
|
by default — and the same chord back): an attached terminal owns every plain
|
||||||
`ctrl-w h`.
|
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:
|
Everything else is the same key on both clients:
|
||||||
|
|
||||||
|
|||||||
+12
-4
@@ -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.
|
back to a short hash of the branch.
|
||||||
|
|
||||||
If that path exists, the daemon appends `-2`, then `-3`, and so on until it finds
|
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
|
a free one.
|
||||||
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
|
For a *new* branch (`create_branch: true`), the suffix goes on the branch name
|
||||||
marks branches that are checked out elsewhere.
|
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
|
### The `worktree_root` override
|
||||||
|
|
||||||
|
|||||||
+23
-22
@@ -4,8 +4,6 @@ import { Terminal } from "@xterm/xterm";
|
|||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
|
|
||||||
import { attachWsUrl } from "./routes.js";
|
import { attachWsUrl } from "./routes.js";
|
||||||
import { FOCUS_SIDEBAR_EVENT } from "./catalog.js";
|
|
||||||
import { CommandSet, defineCommands } from "./commands.js";
|
|
||||||
import { escapeHtml } from "./html.js";
|
import { escapeHtml } from "./html.js";
|
||||||
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
|
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
|
||||||
import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.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 type { ClientControl, Uuid } from "./types.js";
|
||||||
import { WebglPolicy } from "./webglRenderer.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
|
/** One budget for the whole page — see webglRenderer.ts for why contexts are
|
||||||
* rationed rather than handed to every connection. */
|
* rationed rather than handed to every connection. */
|
||||||
const webglPolicy = new WebglPolicy();
|
const webglPolicy = new WebglPolicy();
|
||||||
@@ -221,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
|
// 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:
|
// line at a time and stall), so map finger drags ourselves. Two routes:
|
||||||
// • normal buffer → term.scrollLines walks the scrollback.
|
// • normal buffer → term.scrollLines walks the scrollback.
|
||||||
@@ -379,6 +359,9 @@ export function createAttachConnection(
|
|||||||
let ws: WebSocket;
|
let ws: WebSocket;
|
||||||
let reconnectMs = INITIAL_RECONNECT_MS;
|
let reconnectMs = INITIAL_RECONNECT_MS;
|
||||||
let reconnectTimer: number | null = null;
|
let reconnectTimer: number | null = null;
|
||||||
|
// Snapshot replay starts at row zero, which can leave Codex at its oldest line.
|
||||||
|
// Follow it once per socket; later scrolling remains under the operator's control.
|
||||||
|
let awaitingReplay = true;
|
||||||
|
|
||||||
const sendControl = (msg: ClientControl) => {
|
const sendControl = (msg: ClientControl) => {
|
||||||
if (ws.readyState !== WebSocket.OPEN) return;
|
if (ws.readyState !== WebSocket.OPEN) return;
|
||||||
@@ -398,6 +381,7 @@ export function createAttachConnection(
|
|||||||
};
|
};
|
||||||
|
|
||||||
function connect(): void {
|
function connect(): void {
|
||||||
|
awaitingReplay = true;
|
||||||
ws = new WebSocket(attachWsUrl(sessionId));
|
ws = new WebSocket(attachWsUrl(sessionId));
|
||||||
ws.binaryType = "arraybuffer";
|
ws.binaryType = "arraybuffer";
|
||||||
|
|
||||||
@@ -418,10 +402,20 @@ export function createAttachConnection(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (ev.data instanceof ArrayBuffer) {
|
if (ev.data instanceof ArrayBuffer) {
|
||||||
term.write(new Uint8Array(ev.data));
|
const replay = awaitingReplay;
|
||||||
|
awaitingReplay = false;
|
||||||
|
term.write(new Uint8Array(ev.data), () => {
|
||||||
|
if (replay) term.scrollToBottom();
|
||||||
|
});
|
||||||
} else if (ev.data instanceof Blob) {
|
} else if (ev.data instanceof Blob) {
|
||||||
// Some browsers may default to Blob despite binaryType="arraybuffer".
|
// Some browsers may default to Blob despite binaryType="arraybuffer".
|
||||||
ev.data.arrayBuffer().then((b) => term.write(new Uint8Array(b)));
|
void ev.data.arrayBuffer().then((b) => {
|
||||||
|
const replay = awaitingReplay;
|
||||||
|
awaitingReplay = false;
|
||||||
|
term.write(new Uint8Array(b), () => {
|
||||||
|
if (replay) term.scrollToBottom();
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -470,6 +464,12 @@ export function createAttachConnection(
|
|||||||
const resizeDisp = term.onResize(({ cols, rows }) => {
|
const resizeDisp = term.onResize(({ cols, rows }) => {
|
||||||
sendControl({ type: "Resize", 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);
|
window.addEventListener("resize", onResize);
|
||||||
const visual = window.visualViewport ?? null;
|
const visual = window.visualViewport ?? null;
|
||||||
@@ -525,6 +525,7 @@ export function createAttachConnection(
|
|||||||
visual?.removeEventListener("resize", onResize);
|
visual?.removeEventListener("resize", onResize);
|
||||||
dataDisp.dispose();
|
dataDisp.dispose();
|
||||||
resizeDisp.dispose();
|
resizeDisp.dispose();
|
||||||
|
selectionDisp.dispose();
|
||||||
releaseWebgl();
|
releaseWebgl();
|
||||||
try {
|
try {
|
||||||
ws.close();
|
ws.close();
|
||||||
|
|||||||
@@ -414,19 +414,6 @@ describe("session cards (#107)", () => {
|
|||||||
expect(line2?.textContent).toContain("exited");
|
expect(line2?.textContent).toContain("exited");
|
||||||
});
|
});
|
||||||
|
|
||||||
// #162: a session recovered with no tmux behind it is known but not running,
|
|
||||||
// and never reads as something that exited.
|
|
||||||
it("shows dormant on line 2 for dormant sessions", () => {
|
|
||||||
const s = session({
|
|
||||||
id: "aaaaaaaa-0000-0000-0000-000000000118",
|
|
||||||
status: "Dormant",
|
|
||||||
});
|
|
||||||
const el = renderSidebar(storeWith([s])).el;
|
|
||||||
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
|
|
||||||
expect(line2?.textContent).toContain("dormant");
|
|
||||||
expect(line2?.textContent).not.toContain("exited");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("carries the state word only in tooltips and aria labels, never as text", () => {
|
it("carries the state word only in tooltips and aria labels, never as text", () => {
|
||||||
const s = session({
|
const s = session({
|
||||||
id: "aaaaaaaa-0000-0000-0000-000000000019",
|
id: "aaaaaaaa-0000-0000-0000-000000000019",
|
||||||
|
|||||||
@@ -53,10 +53,6 @@ export function requestOpenCommandBar(): void {
|
|||||||
* connection pool. */
|
* connection pool. */
|
||||||
export const FOCUS_PANE_EVENT = "catalog-focus-pane";
|
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
|
/** Ask the shell to open the spawn modal seeded from an existing session
|
||||||
* ("Spawn similar"). Detail is the session being cloned. */
|
* ("Spawn similar"). Detail is the session being cloned. */
|
||||||
export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar";
|
export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar";
|
||||||
@@ -113,9 +109,6 @@ function activityRailMarkup(slug: string, stateWord: string): string {
|
|||||||
|
|
||||||
function metaLine(s: Session, now: number): string {
|
function metaLine(s: Session, now: number): string {
|
||||||
const dead = s.status === "Dead";
|
const dead = s.status === "Dead";
|
||||||
// #162: known from its sidecar with nothing running behind it — a distinct
|
|
||||||
// fragment, since it never exited.
|
|
||||||
const dormant = s.status === "Dormant";
|
|
||||||
const blockers = sessionBlockers(s);
|
const blockers = sessionBlockers(s);
|
||||||
const slug = activityColorSlug(s.activity ?? "Idle");
|
const slug = activityColorSlug(s.activity ?? "Idle");
|
||||||
const held = activityHeldMs(s.activity_since, now);
|
const held = activityHeldMs(s.activity_since, now);
|
||||||
@@ -130,7 +123,6 @@ function metaLine(s: Session, now: number): string {
|
|||||||
commandFragment(s.command),
|
commandFragment(s.command),
|
||||||
blockers ? blockedFragment(blockers) : "",
|
blockers ? blockedFragment(blockers) : "",
|
||||||
dead ? `<span class="card-flag">exited</span>` : "",
|
dead ? `<span class="card-flag">exited</span>` : "",
|
||||||
dormant ? `<span class="card-flag">dormant</span>` : "",
|
|
||||||
];
|
];
|
||||||
return `<span class="card-line2" data-activity="${slug}">${fragments.join("")}</span>`;
|
return `<span class="card-line2" data-activity="${slug}">${fragments.join("")}</span>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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", () => {
|
it("treats a null last_focused_at as never focused", () => {
|
||||||
const nulled = session("nulled", { last_focused_at: null });
|
const nulled = session("nulled", { last_focused_at: null });
|
||||||
const stamped = session("stamped", { last_focused_at: "2020-01-01T00:00:00Z" });
|
const stamped = session("stamped", { last_focused_at: "2020-01-01T00:00:00Z" });
|
||||||
|
|||||||
@@ -142,10 +142,8 @@ function createdAt(s: Session): number {
|
|||||||
* matcher's last tie-break preserves. */
|
* matcher's last tie-break preserves. */
|
||||||
export function orderSessionsForBar(sessions: readonly Session[]): Session[] {
|
export function orderSessionsForBar(sessions: readonly Session[]): Session[] {
|
||||||
return [...sessions].sort((a, b) => {
|
return [...sessions].sort((a, b) => {
|
||||||
// docs/COMMAND-BAR.md: anything with no live pane — dead or dormant (#162)
|
const deadA = a.status === "Dead" ? 1 : 0;
|
||||||
// — sorts after the sessions you could actually attach to.
|
const deadB = b.status === "Dead" ? 1 : 0;
|
||||||
const deadA = a.status === "Running" ? 0 : 1;
|
|
||||||
const deadB = b.status === "Running" ? 0 : 1;
|
|
||||||
if (deadA !== deadB) return deadA - deadB;
|
if (deadA !== deadB) return deadA - deadB;
|
||||||
|
|
||||||
const focusA = focusedAt(a);
|
const focusA = focusedAt(a);
|
||||||
@@ -352,7 +350,7 @@ function renderRow(result: BarResult, index: number): HTMLElement {
|
|||||||
row.dataset.kind = candidate.kind;
|
row.dataset.kind = candidate.kind;
|
||||||
if (candidate.kind === "session") {
|
if (candidate.kind === "session") {
|
||||||
row.dataset.sessionId = candidate.session.id;
|
row.dataset.sessionId = candidate.session.id;
|
||||||
if (candidate.session.status !== "Running") row.classList.add("is-dead");
|
if (candidate.session.status === "Dead") row.classList.add("is-dead");
|
||||||
} else if (isActionCandidate(candidate)) {
|
} else if (isActionCandidate(candidate)) {
|
||||||
row.dataset.actionId = candidate.action.id;
|
row.dataset.actionId = candidate.action.id;
|
||||||
} else if (candidate.kind === "command") {
|
} else if (candidate.kind === "command") {
|
||||||
|
|||||||
+12
-14
@@ -133,31 +133,28 @@ describe("CommandSet", () => {
|
|||||||
expect(run).toHaveBeenCalledTimes(2);
|
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 plain = vi.fn();
|
||||||
const chorded = vi.fn();
|
const chorded = vi.fn();
|
||||||
const set = new CommandSet(
|
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>");
|
const input = el("<input>");
|
||||||
expect(pressOn(set, input, "n")).toBe(false);
|
expect(pressOn(set, input, "n")).toBe(false);
|
||||||
expect(plain).not.toHaveBeenCalled();
|
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);
|
expect(chorded).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps pane commands live inside the terminal's textarea", () => {
|
it("leaves a plain Escape for the pane instead of binding it", () => {
|
||||||
const run = vi.fn();
|
const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }));
|
||||||
const set = new CommandSet(defineCommands({ "pane.focusList": run }), {
|
|
||||||
preventDefault: false,
|
|
||||||
});
|
|
||||||
const textarea = el('<textarea class="xterm-helper-textarea"></textarea>');
|
const textarea = el('<textarea class="xterm-helper-textarea"></textarea>');
|
||||||
const e = key("Escape");
|
expect(pressOn(set, textarea, "Escape")).toBe(false);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -173,6 +170,7 @@ describe("the leader", () => {
|
|||||||
const byId = new Map(commandSpecs("ctrl-b").map((s) => [s.id, s]));
|
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.literalCtrlK")?.keys).toEqual(["ctrl-b ctrl-k"]);
|
||||||
expect(byId.get("pane.literalLeader")?.keys).toEqual(["ctrl-b ctrl-b"]);
|
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.
|
// Commands that do not hang off the leader are untouched.
|
||||||
expect(byId.get("bar.open")?.keys).toEqual(["ctrl-k"]);
|
expect(byId.get("bar.open")?.keys).toEqual(["ctrl-k"]);
|
||||||
});
|
});
|
||||||
|
|||||||
+2
-8
@@ -93,7 +93,8 @@ const SPECS: readonly SpecSource[] = [
|
|||||||
id: "focus.toggle",
|
id: "focus.toggle",
|
||||||
label: "Move the keyboard between the session list and the pane",
|
label: "Move the keyboard between the session list and the pane",
|
||||||
group: "Session list",
|
group: "Session list",
|
||||||
keys: ["ctrl-shift-h"],
|
keys: [`${DEFAULT_LEADER} h`],
|
||||||
|
leaderKeys: (leader) => [`${leader} h`],
|
||||||
scope: "global",
|
scope: "global",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -145,13 +146,6 @@ const SPECS: readonly SpecSource[] = [
|
|||||||
keys: ["tab"],
|
keys: ["tab"],
|
||||||
scope: "list",
|
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",
|
id: "help.toggle",
|
||||||
label: "Show or hide this help",
|
label: "Show or hide this help",
|
||||||
|
|||||||
@@ -34,12 +34,14 @@ describe("resolveFastForward (#67)", () => {
|
|||||||
const res = resolveFastForward(projects, presets, "amber-falcon");
|
const res = resolveFastForward(projects, presets, "amber-falcon");
|
||||||
expect(res.project?.path).toBe("/repos/fresh");
|
expect(res.project?.path).toBe("/repos/fresh");
|
||||||
expect(res.command).toBe("aider");
|
expect(res.command).toBe("aider");
|
||||||
expect(res.name).toBe("amber-falcon");
|
expect(res.handle).toBe("amber-falcon");
|
||||||
|
|
||||||
const req = buildFastForwardRequest(res)!;
|
const req = buildFastForwardRequest(res)!;
|
||||||
expect(req.cwd).toBe("/repos/fresh");
|
expect(req.cwd).toBe("/repos/fresh");
|
||||||
expect(req.command).toBe("aider");
|
expect(req.command).toBe("aider");
|
||||||
expect(req.metadata).toEqual({ name: "amber-falcon" });
|
// No name (#131): the daemon names the session after its first prompt,
|
||||||
|
// and a name here would collide with the branch handle.
|
||||||
|
expect(req.metadata).toBeUndefined();
|
||||||
expect(req.worktree).toEqual({
|
expect(req.worktree).toEqual({
|
||||||
base_repo: "/repos/fresh",
|
base_repo: "/repos/fresh",
|
||||||
branch: "amber-falcon",
|
branch: "amber-falcon",
|
||||||
@@ -88,9 +90,9 @@ describe("resolveFastForward (#67)", () => {
|
|||||||
expect(buildFastForwardRequest(res)).toBeNull();
|
expect(buildFastForwardRequest(res)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("downgrades to a draft when the generated name is missing", () => {
|
it("downgrades to a draft when the generated handle is missing", () => {
|
||||||
const res = resolveFastForward([project({ last_command: "claude" })], presets);
|
const res = resolveFastForward([project({ last_command: "claude" })], presets);
|
||||||
expect(res.name).toBeUndefined();
|
expect(res.handle).toBeUndefined();
|
||||||
expect(buildFastForwardRequest(res)).toBeNull();
|
expect(buildFastForwardRequest(res)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -108,6 +110,17 @@ describe("resolveFastForward (#67)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("seeds the handle as the branch and leaves the name field empty (#131)", () => {
|
||||||
|
const res = resolveFastForward(
|
||||||
|
[project({ path: "/repos/p", base_repo: "/repos/p", last_command: "aider" })],
|
||||||
|
presets,
|
||||||
|
"amber-falcon",
|
||||||
|
);
|
||||||
|
const seed = fastForwardSeed(res);
|
||||||
|
expect(seed.name).toBe("");
|
||||||
|
expect(seed.worktreeBranch).toBe("amber-falcon");
|
||||||
|
});
|
||||||
|
|
||||||
it("leaves the worktree default alone when not even a project resolved", () => {
|
it("leaves the worktree default alone when not even a project resolved", () => {
|
||||||
// Not `false`: the form defaults the toggle on and a false would stick,
|
// Not `false`: the form defaults the toggle on and a false would stick,
|
||||||
// so the fallback draft would disagree with a normally-opened draft and
|
// so the fallback draft would disagree with a normally-opened draft and
|
||||||
|
|||||||
+19
-13
@@ -10,19 +10,21 @@ import { sortProjects } from "./projectList.js";
|
|||||||
export interface FastForwardResolution {
|
export interface FastForwardResolution {
|
||||||
project?: Project;
|
project?: Project;
|
||||||
command?: string;
|
command?: string;
|
||||||
name?: string;
|
/** A generated handle for the worktree branch (#131). Not a session name:
|
||||||
|
* the fast path names no session. */
|
||||||
|
handle?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The defaults: the currently focused session's project when it's still a
|
/** The defaults: the currently focused session's project when it's still a
|
||||||
* known project, else the most recent one (fresh-first, same ordering the
|
* known project, else the most recent one (fresh-first, same ordering the
|
||||||
* picker shows) — so `N` lands where the operator currently is, matching
|
* picker shows) — so `N` lands where the operator currently is, matching
|
||||||
* what plain `n` seeds from `spawnSimilarProjectSeed`. Its last command
|
* what plain `n` seeds from `spawnSimilarProjectSeed`. Its last command
|
||||||
* falls back to the first non-custom preset, and the name is freshly
|
* falls back to the first non-custom preset, and the branch handle is
|
||||||
* generated when the daemon supplied one. */
|
* freshly generated when the daemon supplied one. */
|
||||||
export function resolveFastForward(
|
export function resolveFastForward(
|
||||||
projects: Project[],
|
projects: Project[],
|
||||||
presets: SpawnPreset[],
|
presets: SpawnPreset[],
|
||||||
name?: string,
|
handle?: string,
|
||||||
focusedCwd?: string,
|
focusedCwd?: string,
|
||||||
): FastForwardResolution {
|
): FastForwardResolution {
|
||||||
const project =
|
const project =
|
||||||
@@ -30,41 +32,45 @@ export function resolveFastForward(
|
|||||||
const lastCommand = project?.last_command?.trim();
|
const lastCommand = project?.last_command?.trim();
|
||||||
const command =
|
const command =
|
||||||
lastCommand || presets.find((p) => p.command !== "")?.command || undefined;
|
lastCommand || presets.find((p) => p.command !== "")?.command || undefined;
|
||||||
return { project, command, name: name || undefined };
|
return { project, command, handle: handle || undefined };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The immediate spawn request — worktree on, new branch named after the
|
/** The immediate spawn request — worktree on, new branch named after the
|
||||||
* generated session name — or `null` when any default is missing. */
|
* generated handle — or `null` when any default is missing.
|
||||||
|
*
|
||||||
|
* The request carries no name (#131): the daemon assigns a provisional one
|
||||||
|
* and replaces it once the session sees its first prompt. A name here would
|
||||||
|
* also collide with the branch handle's own session. */
|
||||||
export function buildFastForwardRequest(
|
export function buildFastForwardRequest(
|
||||||
res: FastForwardResolution,
|
res: FastForwardResolution,
|
||||||
): CreateSessionRequest | null {
|
): CreateSessionRequest | null {
|
||||||
const { project, command, name } = res;
|
const { project, command, handle } = res;
|
||||||
if (!project || !command?.trim() || !name) return null;
|
if (!project || !command?.trim() || !handle) return null;
|
||||||
return {
|
return {
|
||||||
cwd: project.path,
|
cwd: project.path,
|
||||||
command,
|
command,
|
||||||
metadata: { name },
|
|
||||||
worktree: {
|
worktree: {
|
||||||
base_repo: project.base_repo ?? project.path,
|
base_repo: project.base_repo ?? project.path,
|
||||||
branch: name,
|
branch: handle,
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Seed for the fallback spawn form: whatever did resolve goes into the
|
/** Seed for the fallback spawn form: whatever did resolve goes into the
|
||||||
* form; the rest stays with its own defaults. */
|
* form; the rest stays with its own defaults. The name field stays empty
|
||||||
|
* (#131) — the handle seeds the branch, not the session name. */
|
||||||
export function fastForwardSeed(res: FastForwardResolution) {
|
export function fastForwardSeed(res: FastForwardResolution) {
|
||||||
return {
|
return {
|
||||||
cwd: res.project?.path ?? "",
|
cwd: res.project?.path ?? "",
|
||||||
command: res.command ?? "",
|
command: res.command ?? "",
|
||||||
name: res.name ?? "",
|
name: "",
|
||||||
// Left undefined rather than false when nothing resolved: the form
|
// Left undefined rather than false when nothing resolved: the form
|
||||||
// defaults the toggle on, and `false` would stick, so the fallback form
|
// defaults the toggle on, and `false` would stick, so the fallback form
|
||||||
// would disagree with both a normally-opened form and the TUI's fast path
|
// would disagree with both a normally-opened form and the TUI's fast path
|
||||||
// (#67 review).
|
// (#67 review).
|
||||||
worktreeEnabled: res.project ? true : undefined,
|
worktreeEnabled: res.project ? true : undefined,
|
||||||
worktreeBranch:
|
worktreeBranch:
|
||||||
res.project && res.name ? res.name : undefined,
|
res.project && res.handle ? res.handle : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -132,9 +132,9 @@ function mount(p: KillDialogSpec): HTMLElement {
|
|||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKeydown, true);
|
window.addEventListener("keydown", onKeydown, true);
|
||||||
open = { el, onKeydown };
|
open = { el, onKeydown };
|
||||||
// Cancel takes the focus in both prompts: a kill is never one keystroke
|
// The acting button takes the focus in both prompts: the operator already
|
||||||
// away from whatever asked for the dialog.
|
// asked for the kill, so Enter confirms it. Escape and the scrim cancel.
|
||||||
cancel.focus();
|
confirm.focus();
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
emptyLayout,
|
emptyLayout,
|
||||||
|
equalizeRatios,
|
||||||
leaves,
|
leaves,
|
||||||
locateSession,
|
locateSession,
|
||||||
openSessions,
|
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", () => {
|
describe("removeLeaf", () => {
|
||||||
it("collapses the parent split into the surviving sibling", () => {
|
it("collapses the parent split into the surviving sibling", () => {
|
||||||
const tab = tabWithOneLeaf("s1");
|
const tab = tabWithOneLeaf("s1");
|
||||||
|
|||||||
@@ -118,6 +118,17 @@ export function removeLeaf(tab: Tab, leafId: string): void {
|
|||||||
tab.root = tab.root ? rec(tab.root) : null;
|
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 {
|
export function addTab(layout: Layout): Tab {
|
||||||
const tab: Tab = {
|
const tab: Tab = {
|
||||||
id: freshId("t"),
|
id: freshId("t"),
|
||||||
|
|||||||
+11
-15
@@ -35,7 +35,6 @@ import { loadLayout, saveLayout } from "./persistence.js";
|
|||||||
import { createPreviewSchedule } from "./previewSchedule.js";
|
import { createPreviewSchedule } from "./previewSchedule.js";
|
||||||
import {
|
import {
|
||||||
FOCUS_PANE_EVENT,
|
FOCUS_PANE_EVENT,
|
||||||
FOCUS_SIDEBAR_EVENT,
|
|
||||||
OPEN_COMMAND_BAR_EVENT,
|
OPEN_COMMAND_BAR_EVENT,
|
||||||
OPEN_SPAWN_EVENT,
|
OPEN_SPAWN_EVENT,
|
||||||
SESSION_SELECT_EVENT,
|
SESSION_SELECT_EVENT,
|
||||||
@@ -356,14 +355,14 @@ async function spawnPresets(): Promise<SpawnPreset[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function requestFastForward(): Promise<void> {
|
async function requestFastForward(): Promise<void> {
|
||||||
const [discovered, name] = await Promise.all([
|
const [discovered, handle] = await Promise.all([
|
||||||
fetchProjects().catch(() => [] as Project[]),
|
fetchProjects().catch(() => [] as Project[]),
|
||||||
fetchNames(1)
|
fetchNames(1)
|
||||||
.then((batch) => batch.names[0])
|
.then((batch) => batch.names[0])
|
||||||
.catch(() => undefined),
|
.catch(() => undefined),
|
||||||
]);
|
]);
|
||||||
const presets = await spawnPresets();
|
const presets = await spawnPresets();
|
||||||
const resolved = resolveFastForward(discovered, presets, name, focusedProjectCwd());
|
const resolved = resolveFastForward(discovered, presets, handle, focusedProjectCwd());
|
||||||
const req = buildFastForwardRequest(resolved);
|
const req = buildFastForwardRequest(resolved);
|
||||||
if (req) {
|
if (req) {
|
||||||
try {
|
try {
|
||||||
@@ -390,7 +389,7 @@ async function requestFastForward(): Promise<void> {
|
|||||||
// one outside any repo spawns without a worktree, since there is no repo to cut
|
// one outside any repo spawns without a worktree, since there is no repo to cut
|
||||||
// a branch from. Anything still unresolved opens the pre-filled form.
|
// a branch from. Anything still unresolved opens the pre-filled form.
|
||||||
async function requestSpawnInDirectory(path: string): Promise<void> {
|
async function requestSpawnInDirectory(path: string): Promise<void> {
|
||||||
const [discovered, name] = await Promise.all([
|
const [discovered, handle] = await Promise.all([
|
||||||
fetchProjects().catch(() => [] as Project[]),
|
fetchProjects().catch(() => [] as Project[]),
|
||||||
fetchNames(1)
|
fetchNames(1)
|
||||||
.then((batch) => batch.names[0])
|
.then((batch) => batch.names[0])
|
||||||
@@ -399,7 +398,7 @@ async function requestSpawnInDirectory(path: string): Promise<void> {
|
|||||||
const presets = await spawnPresets();
|
const presets = await spawnPresets();
|
||||||
const known = discovered.find((p) => p.path === path);
|
const known = discovered.find((p) => p.path === path);
|
||||||
const project: Project = known ?? { path, name: path.split("/").filter(Boolean).pop() ?? path };
|
const project: Project = known ?? { path, name: path.split("/").filter(Boolean).pop() ?? path };
|
||||||
const resolved = resolveFastForward([project], presets, name, path);
|
const resolved = resolveFastForward([project], presets, handle, path);
|
||||||
const req = buildFastForwardRequest(resolved);
|
const req = buildFastForwardRequest(resolved);
|
||||||
if (req) {
|
if (req) {
|
||||||
const inRepo =
|
const inRepo =
|
||||||
@@ -440,10 +439,11 @@ const spawnKeys = new CommandSet(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Ctrl+Shift+H moves the keyboard between the session list and the pane, the
|
// Leader-h moves the keyboard between the session list and the pane, the web
|
||||||
// web counterpart of the TUI's `ctrl-w h`. It has to be a chord: an attached
|
// counterpart of the TUI's `ctrl-w h`. It has to be a chord behind the
|
||||||
// terminal owns every plain keystroke, so nothing unmodified could ever get
|
// leader: an attached terminal owns every plain keystroke, and Ctrl+Shift+H
|
||||||
// the focus back out of it.
|
// 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
|
// 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
|
// on `/api/info`): it holds the key as a partial chord and takes select-all off
|
||||||
@@ -528,8 +528,8 @@ window.addEventListener(
|
|||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
// Tab in the session list hands the keyboard to the pane; Escape in the pane
|
// Tab in the session list hands the keyboard to the pane; leader-h (focus.toggle,
|
||||||
// hands it back — the pair the TUI gets from `tab` and `esc`.
|
// above) hands it back the other way.
|
||||||
document.addEventListener(FOCUS_PANE_EVENT, () => {
|
document.addEventListener(FOCUS_PANE_EVENT, () => {
|
||||||
// Handing the keyboard to a previewed pane is choosing it: the preview stops
|
// Handing the keyboard to a previewed pane is choosing it: the preview stops
|
||||||
// being provisional rather than snapping back the moment the list blurs.
|
// being provisional rather than snapping back the moment the list blurs.
|
||||||
@@ -537,10 +537,6 @@ document.addEventListener(FOCUS_PANE_EVENT, () => {
|
|||||||
if (previewed) focusSession(previewed);
|
if (previewed) focusSession(previewed);
|
||||||
pool.focusTerminal();
|
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
|
/** 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
|
* hash router and the sidebar's click-driven path (#133), which can't rely
|
||||||
|
|||||||
+21
-3
@@ -10,6 +10,7 @@ import {
|
|||||||
activeTab,
|
activeTab,
|
||||||
addTab,
|
addTab,
|
||||||
closeTab,
|
closeTab,
|
||||||
|
equalizeRatios,
|
||||||
findLeaf,
|
findLeaf,
|
||||||
freshId,
|
freshId,
|
||||||
type Dir,
|
type Dir,
|
||||||
@@ -215,6 +216,20 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
return fl?.sessionId ?? null;
|
return fl?.sessionId ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The session the operator actually picked — what `focusedLeafSession()`
|
||||||
|
* would return with the preview undone. A preview moves the active tab and
|
||||||
|
* its focus, but it is a view: the accent stays on the row the operator
|
||||||
|
* chose until they choose another (docs/PREVIEW.md). */
|
||||||
|
function realFocusedSession(): string | null {
|
||||||
|
const p = preview;
|
||||||
|
if (!p) return focusedLeafSession();
|
||||||
|
const tab = layout.tabs.find((t) => t.id === p.prevActiveTabId) ?? activeTab(layout);
|
||||||
|
const leafId =
|
||||||
|
p.prevFocus && p.prevFocus.tabId === tab.id ? p.prevFocus.leafId : tab.focusedLeafId;
|
||||||
|
const fl = leafId ? findLeaf(tab.root, leafId) : null;
|
||||||
|
return fl?.sessionId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Update focus styling + pool focus + sidebar highlights without rebuilding
|
/** Update focus styling + pool focus + sidebar highlights without rebuilding
|
||||||
* the DOM (cheap path for click-to-focus). */
|
* the DOM (cheap path for click-to-focus). */
|
||||||
function syncFocus(): void {
|
function syncFocus(): void {
|
||||||
@@ -233,7 +248,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
if (!preview) pool.setFocus(focusedLeafSession());
|
if (!preview) pool.setFocus(focusedLeafSession());
|
||||||
sidebar.setHighlights({
|
sidebar.setHighlights({
|
||||||
open: realOpenSessions(),
|
open: realOpenSessions(),
|
||||||
focused: focusedLeafSession(),
|
focused: realFocusedSession(),
|
||||||
});
|
});
|
||||||
// The row the preview came from wears the same colour, so the sidebar and
|
// The row the preview came from wears the same colour, so the sidebar and
|
||||||
// the pane read as one thing rather than two unrelated highlights.
|
// the pane read as one thing rather than two unrelated highlights.
|
||||||
@@ -443,6 +458,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
dropPreview();
|
dropPreview();
|
||||||
const tab = activeTab(layout);
|
const tab = activeTab(layout);
|
||||||
tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true);
|
tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true);
|
||||||
|
equalizeRatios(tab.root);
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,6 +544,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
target.sessionId = sessionId;
|
target.sessionId = sessionId;
|
||||||
} else {
|
} else {
|
||||||
tab.focusedLeafId = splitLeaf(tab, target.id, sessionId, "row", true);
|
tab.focusedLeafId = splitLeaf(tab, target.id, sessionId, "row", true);
|
||||||
|
equalizeRatios(tab.root);
|
||||||
}
|
}
|
||||||
render();
|
render();
|
||||||
opts.onOpenSessionsChange?.();
|
opts.onOpenSessionsChange?.();
|
||||||
@@ -608,7 +625,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
{ focus: !preview && !quietRender },
|
{ focus: !preview && !quietRender },
|
||||||
);
|
);
|
||||||
if (!preview) pool.setFocus(focused.sessionId ?? null);
|
if (!preview) pool.setFocus(focused.sessionId ?? null);
|
||||||
sidebar.setHighlights({ open: realOpenSessions(), focused: focused.sessionId ?? null });
|
sidebar.setHighlights({ open: realOpenSessions(), focused: realFocusedSession() });
|
||||||
sidebar.setPreview(preview?.sessionId ?? null);
|
sidebar.setPreview(preview?.sessionId ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -801,6 +818,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
if (source.kind === "pane" && self) removeLeaf(tab, self);
|
if (source.kind === "pane" && self) removeLeaf(tab, self);
|
||||||
else if (loc) removeLeaf(loc.tab, loc.leaf.id);
|
else if (loc) removeLeaf(loc.tab, loc.leaf.id);
|
||||||
tab.focusedLeafId = splitLeaf(tab, targetLeafId, sessionId, dir, after);
|
tab.focusedLeafId = splitLeaf(tab, targetLeafId, sessionId, dir, after);
|
||||||
|
equalizeRatios(tab.root);
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -817,7 +835,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
openSessions: realOpenSessions,
|
openSessions: realOpenSessions,
|
||||||
beginSessionDrag,
|
beginSessionDrag,
|
||||||
render,
|
render,
|
||||||
focusedSessionId: focusedLeafSession,
|
focusedSessionId: realFocusedSession,
|
||||||
previewSession,
|
previewSession,
|
||||||
previewedSessionId: () => preview?.sessionId ?? null,
|
previewedSessionId: () => preview?.sessionId ?? null,
|
||||||
};
|
};
|
||||||
|
|||||||
+33
-1
@@ -47,12 +47,20 @@ function fakePool(): Spy {
|
|||||||
|
|
||||||
/** Last value the controller pushed to `SidebarHandle.setPreview`. */
|
/** Last value the controller pushed to `SidebarHandle.setPreview`. */
|
||||||
const previewRow: { id: string | null } = { id: null };
|
const previewRow: { id: string | null } = { id: null };
|
||||||
|
/** Last highlights the controller pushed to `SidebarHandle.setHighlights`. */
|
||||||
|
const rowHighlights: { open: Set<string>; focused: string | null } = {
|
||||||
|
open: new Set(),
|
||||||
|
focused: null,
|
||||||
|
};
|
||||||
|
|
||||||
function fakeSidebar(): SidebarHandle {
|
function fakeSidebar(): SidebarHandle {
|
||||||
return {
|
return {
|
||||||
el: document.createElement("aside"),
|
el: document.createElement("aside"),
|
||||||
setActive() {},
|
setActive() {},
|
||||||
setHighlights() {},
|
setHighlights(h) {
|
||||||
|
rowHighlights.open = h.open;
|
||||||
|
rowHighlights.focused = h.focused;
|
||||||
|
},
|
||||||
setPreview(id: string | null) {
|
setPreview(id: string | null) {
|
||||||
previewRow.id = id;
|
previewRow.id = id;
|
||||||
},
|
},
|
||||||
@@ -95,6 +103,8 @@ describe("sidebar preview", () => {
|
|||||||
stubFetch();
|
stubFetch();
|
||||||
document.body.replaceChildren();
|
document.body.replaceChildren();
|
||||||
previewRow.id = null;
|
previewRow.id = null;
|
||||||
|
rowHighlights.open = new Set();
|
||||||
|
rowHighlights.focused = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -134,6 +144,28 @@ describe("sidebar preview", () => {
|
|||||||
expect(mv.el.querySelectorAll(".mv-pane.is-preview")).toHaveLength(0);
|
expect(mv.el.querySelectorAll(".mv-pane.is-preview")).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("leaves the accent on the session the pane actually holds", () => {
|
||||||
|
const layout = twoTabs();
|
||||||
|
const spy = fakePool();
|
||||||
|
const mv = makeMv(layout, spy.pool);
|
||||||
|
expect(rowHighlights.focused).toBe("s2");
|
||||||
|
|
||||||
|
// Walking past other rows never moves the accent off what the operator
|
||||||
|
// picked: only a real open does (docs/PREVIEW.md).
|
||||||
|
mv.previewSession("s1");
|
||||||
|
expect(rowHighlights.focused).toBe("s2");
|
||||||
|
expect(mv.focusedSessionId()).toBe("s2");
|
||||||
|
|
||||||
|
mv.previewSession("s9");
|
||||||
|
expect(rowHighlights.focused).toBe("s2");
|
||||||
|
|
||||||
|
mv.previewSession(null);
|
||||||
|
expect(rowHighlights.focused).toBe("s2");
|
||||||
|
|
||||||
|
mv.openSession("s1");
|
||||||
|
expect(rowHighlights.focused).toBe("s1");
|
||||||
|
});
|
||||||
|
|
||||||
it("shows a session no pane holds in a throwaway tab that leaves no trace", () => {
|
it("shows a session no pane holds in a throwaway tab that leaves no trace", () => {
|
||||||
const layout = twoTabs();
|
const layout = twoTabs();
|
||||||
const before = serializeLayout(layout);
|
const before = serializeLayout(layout);
|
||||||
|
|||||||
+1
-4
@@ -6,10 +6,7 @@ import { escapeHtml } from "./html.js";
|
|||||||
export type Uuid = string;
|
export type Uuid = string;
|
||||||
export type IsoTimestamp = string;
|
export type IsoTimestamp = string;
|
||||||
|
|
||||||
/** `Dormant` (#162) is a session the daemon knows only from its sidecar —
|
export type SessionStatus = "Running" | "Dead";
|
||||||
* recovered after the tmux server died under it, so there is no pane behind it
|
|
||||||
* and nothing to attach to. Listed, never live. */
|
|
||||||
export type SessionStatus = "Running" | "Dead" | "Dormant";
|
|
||||||
|
|
||||||
/** Daemon-inferred activity dimension, orthogonal to `SessionStatus` (NAP-340).
|
/** Daemon-inferred activity dimension, orthogonal to `SessionStatus` (NAP-340).
|
||||||
* Absent on payloads from daemons that predate the field. `Error` is only set
|
* Absent on payloads from daemons that predate the field. `Error` is only set
|
||||||
|
|||||||
Reference in New Issue
Block a user