From a89d9ea45baff6c0bb2d983c711b977a87d6d4b5 Mon Sep 17 00:00:00 2001 From: l0ng-ai Date: Tue, 4 Aug 2026 07:23:20 +0800 Subject: [PATCH] fix(cli): diagnose unavailable agent hooks (#321) Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> --- crates/tty7-cli/src/backend.rs | 15 ++ crates/tty7-cli/src/backend/real.rs | 11 + crates/tty7-cli/src/commands.rs | 254 ++++++++++++++++++++++- crates/tty7-cli/src/gui.rs | 5 +- crates/tty7-core/src/core/agent_hooks.rs | 55 ++++- 5 files changed, 329 insertions(+), 11 deletions(-) diff --git a/crates/tty7-cli/src/backend.rs b/crates/tty7-cli/src/backend.rs index ccf249ff..52ed5347 100644 --- a/crates/tty7-cli/src/backend.rs +++ b/crates/tty7-cli/src/backend.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use tty7_core::core::agent_hooks::{HookAgent, HooksState}; use tty7_core::core::session::WorkspaceId; use tty7_core::daemon::control::{ControlEvent, ControlHelloOk, ControlRequest, ReplyOk}; use tty7_core::daemon::protocol::{PaneInfo, PaneProcs, WinSize}; @@ -42,6 +43,11 @@ pub trait Backend { fn procs(&mut self, pane: u64) -> Result; + /// The install state for an agent's status hooks on the machine this + /// backend addresses. Routed machines return `None`: their config belongs + /// to the remote host and must not be guessed from the local filesystem. + fn agent_hooks_state(&mut self, agent: HookAgent) -> Option; + /// Every pane the server is actually running, straight from its registry — /// including ones no workspace tree references. The machine tree cannot see /// those, so this is the only way an orphan becomes visible. @@ -63,6 +69,7 @@ pub mod mock { use std::collections::VecDeque; use anyhow::Result; + use tty7_core::core::agent_hooks::{HookAgent, HooksState}; use tty7_core::core::machine::Machine; use tty7_core::core::session::WorkspaceId; use tty7_core::daemon::control::{ @@ -83,6 +90,7 @@ pub mod mock { pub capture_segments: Vec, pub procs_calls: Vec, pub procs_reply: PaneProcs, + pub agent_hooks_states: Vec<(HookAgent, HooksState)>, pub registry: Vec, pub killed: Vec, pub kill_failures: Vec, @@ -104,6 +112,7 @@ pub mod mock { capture_segments: Vec::new(), procs_calls: Vec::new(), procs_reply: PaneProcs::default(), + agent_hooks_states: Vec::new(), registry: Vec::new(), killed: Vec::new(), kill_failures: Vec::new(), @@ -167,6 +176,12 @@ pub mod mock { Ok(self.procs_reply.clone()) } + fn agent_hooks_state(&mut self, agent: HookAgent) -> Option { + self.agent_hooks_states + .iter() + .find_map(|(candidate, state)| (*candidate == agent).then_some(*state)) + } + fn list_panes(&mut self) -> Result> { Ok(self.registry.clone()) } diff --git a/crates/tty7-cli/src/backend/real.rs b/crates/tty7-cli/src/backend/real.rs index b848f43e..af529fd5 100644 --- a/crates/tty7-cli/src/backend/real.rs +++ b/crates/tty7-cli/src/backend/real.rs @@ -4,6 +4,7 @@ use std::time::Duration; use anyhow::{Context as _, Result, anyhow, bail}; use serde_json::json; use tty7_core::client::{ControlClient, PaneClient, PaneSession}; +use tty7_core::core::agent_hooks::{HookAgent, HookTarget, HooksState, hooks_state}; use tty7_core::core::session::WorkspaceId; use tty7_core::daemon::control::{ ControlEvent, ControlHello, ControlHelloOk, ControlRequest, ReplyOk, RouteInfo, @@ -194,6 +195,16 @@ impl Backend for RealBackend { Ok(procs) } + fn agent_hooks_state(&mut self, agent: HookAgent) -> Option { + if self.machine.is_some() { + return None; + } + let app = crate::gui::find_executable().ok()?; + let host = tty7_core::host::local::LocalHost::new(); + let target = HookTarget::local_for_exe(&*host, app)?; + Some(hooks_state(&target, agent)) + } + fn list_panes(&mut self) -> Result> { let panes = self .pane_client()? diff --git a/crates/tty7-cli/src/commands.rs b/crates/tty7-cli/src/commands.rs index 00bb1ff8..b9b6aa02 100644 --- a/crates/tty7-cli/src/commands.rs +++ b/crates/tty7-cli/src/commands.rs @@ -1,6 +1,7 @@ use anyhow::{Context as _, Result, bail}; use serde_json::{Value, json}; use std::time::Duration; +use tty7_core::core::agent_hooks::{HookAgent, HooksState}; use tty7_core::core::machine::{Axis, Machine, PaneSeed, Workspace}; use tty7_core::core::session::WorkspaceId; use tty7_core::daemon::control::{CONTROL_VERSION, ControlEvent, ControlRequest, ReplyOk}; @@ -880,14 +881,130 @@ fn pane_is_live(backend: &mut dyn Backend, pane: u64) -> Result { fn agents(backend: &mut dyn Backend) -> Result { match backend.control(ControlRequest::AgentStates)? { - ReplyOk::AgentStates(states) => report( - output::agents_table(&states), - json!({ "agents": serde_json::to_value(&states)? }), - ), + ReplyOk::AgentStates(states) => { + // AgentStates only contains panes that have already emitted a hook + // event. The machine snapshot independently records the daemon's + // foreground-process detection, including a supported agent that + // has not been able to report yet. + let diagnostics = fetch_machine(backend) + .map(|machine| agent_hook_diagnostics(&states, &machine, backend)) + .unwrap_or_default(); + let mut json = json!({ "agents": serde_json::to_value(&states)? }); + if !diagnostics.is_empty() { + json["diagnostics"] = + Value::Array(diagnostics.iter().map(AgentHookDiagnostic::json).collect()); + } + report(agents_human(&states, &diagnostics), json) + } other => bail!("the server answered AgentStates with {other:?}"), } } +/// The two hook states worth reporting. Building one of these is the only way +/// to reach a diagnostic, so "installed hooks are not a diagnostic" is a shape +/// the type cannot hold rather than a branch that has to stay unreachable. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum HookGap { + Missing, + Outdated, +} + +impl HookGap { + fn of(state: HooksState) -> Option { + match state { + HooksState::NotInstalled => Some(HookGap::Missing), + HooksState::Outdated => Some(HookGap::Outdated), + HooksState::Installed => None, + } + } + + /// The verb of the Settings button that closes the gap. + fn action(self) -> &'static str { + match self { + HookGap::Missing => "install", + HookGap::Outdated => "update", + } + } + + fn slug(self) -> &'static str { + match self { + HookGap::Missing => "not_installed", + HookGap::Outdated => "outdated", + } + } + + fn describe(self) -> &'static str { + match self { + HookGap::Missing => "not installed", + HookGap::Outdated => "outdated", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct AgentHookDiagnostic { + agent: HookAgent, + gap: HookGap, +} + +impl AgentHookDiagnostic { + fn json(&self) -> Value { + json!({ + "kind": "agent_status_hooks_unavailable", + "agent": self.agent.slug(), + "hooks_state": self.gap.slug(), + "action": self.gap.action(), + }) + } +} + +fn agent_hook_diagnostics( + states: &[tty7_core::daemon::control::PaneAgentState], + machine: &Machine, + backend: &mut dyn Backend, +) -> Vec { + HookAgent::ALL + .into_iter() + .filter(|hook_agent| { + machine.panes.iter().any(|pane| { + pane.live + && !states.iter().any(|state| state.pane_id == pane.id) + && pane.agent.as_ref().is_some_and(|facts| { + HookAgent::of_detected(facts.agent) == Some(*hook_agent) + }) + }) + }) + .filter_map(|agent| { + let gap = HookGap::of(backend.agent_hooks_state(agent)?)?; + Some(AgentHookDiagnostic { agent, gap }) + }) + .collect() +} + +fn agents_human( + states: &[tty7_core::daemon::control::PaneAgentState], + diagnostics: &[AgentHookDiagnostic], +) -> String { + if diagnostics.is_empty() { + return output::agents_table(states); + } + let mut human = if states.is_empty() { + "no agents reporting status\n".to_string() + } else { + output::agents_table(states) + }; + for diagnostic in diagnostics { + let state = diagnostic.gap.describe(); + human.push_str(&format!( + "{} is running, but its tty7 agent-status hooks are {state}. Open Settings → Agents to {} the hooks, then start a new {} session.\n", + diagnostic.agent.display_name(), + diagnostic.gap.action(), + diagnostic.agent.display_name(), + )); + } + human +} + fn status(backend: &mut dyn Backend) -> Result { match backend.control(ControlRequest::Status)? { ReplyOk::Status(status) => report( @@ -994,6 +1111,7 @@ mod tests { use crate::backend::mock::MockBackend; use crate::testbed::two_workspace_machine; use clap::Parser; + use tty7_core::core::cli_agent::CLIAgent; use tty7_core::core::machine::Tab; fn cli(args: &[&str]) -> Cli { @@ -1943,6 +2061,24 @@ mod tests { } } + fn detect_agent(backend: &mut MockBackend, pane_id: u64, agent: CLIAgent) { + use tty7_core::core::machine::AgentFacts; + + let pane = backend + .machine + .panes + .iter_mut() + .find(|pane| pane.id == pane_id) + .expect("test pane exists"); + pane.live = true; + pane.agent = Some(AgentFacts { + agent, + session_id: None, + launch_argv: None, + status: None, + }); + } + /// The happy path is one aggregate poll: a matching agent state answers /// immediately, carrying the event's message and native session id — the /// two things an orchestrator needs to act on the wake-up. @@ -2142,14 +2278,116 @@ mod tests { } #[test] - fn agents_status_and_machine_ls_are_single_aggregate_requests() { - use tty7_core::daemon::control::{RouteInfo, ServerStatus}; + fn agents_distinguishes_no_agent_from_a_healthy_agent() { + use tty7_core::core::cli_agent::AgentStatus; let mut backend = mock(); backend.replies.push_back(ReplyOk::AgentStates(Vec::new())); let out = run_cli(&["tty7", "agents"], &Context::default(), &mut backend); - assert_eq!(backend.control_calls, vec![ControlRequest::AgentStates]); - assert!(human(out).contains("no agents"), "an empty panel says so"); + assert_eq!( + backend.control_calls, + vec![ControlRequest::AgentStates, ControlRequest::MachineGet] + ); + assert_eq!(human(out), "no agents running\n"); + + let mut backend = mock(); + detect_agent(&mut backend, 1, CLIAgent::Codex); + backend.agent_hooks_states = vec![(HookAgent::Codex, HooksState::NotInstalled)]; + let mut reporting = agent_state(1, AgentStatus::Working); + reporting.agent = Some(CLIAgent::Codex); + backend + .replies + .push_back(ReplyOk::AgentStates(vec![reporting])); + let out = run_cli(&["tty7", "agents"], &Context::default(), &mut backend); + let rendered = human(out); + assert!(rendered.contains("codex"), "{rendered}"); + assert!( + !rendered.contains("hooks"), + "an agent already reporting status is healthy: {rendered}" + ); + + let mut backend = mock(); + detect_agent(&mut backend, 1, CLIAgent::Codex); + backend.agent_hooks_states = vec![(HookAgent::Codex, HooksState::Installed)]; + backend.replies.push_back(ReplyOk::AgentStates(Vec::new())); + let out = run_cli(&["tty7", "agents"], &Context::default(), &mut backend); + assert_eq!( + human(out), + "no agents running\n", + "installed hooks are not diagnosed during the gap before the first event" + ); + } + + #[test] + fn agents_reports_missing_hooks_once_per_agent_without_failing() { + let mut backend = mock(); + detect_agent(&mut backend, 1, CLIAgent::Codex); + detect_agent(&mut backend, 2, CLIAgent::Codex); + backend.agent_hooks_states = vec![(HookAgent::Codex, HooksState::NotInstalled)]; + backend.replies.push_back(ReplyOk::AgentStates(Vec::new())); + + let out = run_cli(&["tty7", "agents"], &Context::default(), &mut backend); + let Outcome::Report(report) = out else { + panic!("a successful diagnosis must not be a command failure"); + }; + assert_eq!(report.human.matches("Codex is running").count(), 1); + assert!(report.human.contains("hooks are not installed")); + assert!(report.human.contains("install the hooks")); + assert_eq!( + report.json, + json!({ + "agents": [], + "diagnostics": [{ + "kind": "agent_status_hooks_unavailable", + "agent": "codex", + "hooks_state": "not_installed", + "action": "install", + }], + }) + ); + } + + #[test] + fn agents_reports_outdated_hooks_with_the_update_action() { + let mut backend = mock(); + detect_agent(&mut backend, 3, CLIAgent::Claude); + backend.agent_hooks_states = vec![(HookAgent::Claude, HooksState::Outdated)]; + backend.replies.push_back(ReplyOk::AgentStates(Vec::new())); + + let report = match run_cli(&["tty7", "agents"], &Context::default(), &mut backend) { + Outcome::Report(report) => report, + Outcome::Exit(code, _) => panic!("diagnosis unexpectedly exited {code}"), + }; + assert!(report.human.contains("Claude Code is running")); + assert!(report.human.contains("hooks are outdated")); + assert!(report.human.contains("update the hooks")); + assert_eq!(report.json["diagnostics"][0]["agent"], "claude"); + assert_eq!(report.json["diagnostics"][0]["hooks_state"], "outdated"); + assert_eq!(report.json["diagnostics"][0]["action"], "update"); + } + + #[test] + fn agents_json_keeps_the_existing_agents_shape_when_there_is_no_diagnostic() { + use tty7_core::core::cli_agent::AgentStatus; + + let mut backend = mock(); + let mut reporting = agent_state(1, AgentStatus::Waiting); + reporting.agent = Some(CLIAgent::Codex); + backend + .replies + .push_back(ReplyOk::AgentStates(vec![reporting.clone()])); + let json = json_of(run_cli( + &["tty7", "--json", "agents"], + &Context::default(), + &mut backend, + )); + assert_eq!(json, json!({ "agents": [reporting] })); + assert!(json.get("diagnostics").is_none()); + } + + #[test] + fn status_and_machine_ls_are_single_aggregate_requests() { + use tty7_core::daemon::control::{RouteInfo, ServerStatus}; let mut backend = mock(); backend.replies.push_back(ReplyOk::Status(ServerStatus { diff --git a/crates/tty7-cli/src/gui.rs b/crates/tty7-cli/src/gui.rs index a8942056..ee5c435f 100644 --- a/crates/tty7-cli/src/gui.rs +++ b/crates/tty7-cli/src/gui.rs @@ -21,7 +21,10 @@ pub fn launch(path: Option<&Path>) -> Result<()> { Ok(()) } -fn find_executable() -> Result { +/// The bundled `tty7-app` this CLI belongs to: `TTY7_APP`, then the file next +/// to this executable, then `PATH`. `gui` launches it; hook diagnosis needs the +/// same path because that is the executable a hook command names. +pub fn find_executable() -> Result { if let Some(explicit) = std::env::var_os("TTY7_APP") { let path = PathBuf::from(explicit); if path.is_file() { diff --git a/crates/tty7-core/src/core/agent_hooks.rs b/crates/tty7-core/src/core/agent_hooks.rs index 7e442cc6..b58710bc 100644 --- a/crates/tty7-core/src/core/agent_hooks.rs +++ b/crates/tty7-core/src/core/agent_hooks.rs @@ -2,7 +2,7 @@ use std::io; use std::io::{IsTerminal as _, Read as _}; use std::path::{Path, PathBuf}; -use crate::core::cli_agent::AGENT_EVENT_SENTINEL; +use crate::core::cli_agent::{AGENT_EVENT_SENTINEL, CLIAgent}; use crate::host::Host; pub const TTY7_ENV_MARKER: &str = "TTY7"; @@ -225,6 +225,33 @@ impl HookAgent { HookAgent::Grok, ]; + /// The hooks behind a detected agent process, if it has any. + /// + /// Process detection knows far more agents than hooks do — the ones with no + /// arm here report status some other way, or not at all. The match is + /// exhaustive on purpose: a newly detected agent has to say which it is. + pub fn of_detected(agent: CLIAgent) -> Option { + match agent { + CLIAgent::Claude => Some(HookAgent::Claude), + CLIAgent::Codex => Some(HookAgent::Codex), + CLIAgent::Copilot => Some(HookAgent::Copilot), + CLIAgent::OpenCode => Some(HookAgent::OpenCode), + CLIAgent::Pi => Some(HookAgent::Pi), + CLIAgent::Grok => Some(HookAgent::Grok), + CLIAgent::Gemini + | CLIAgent::Aider + | CLIAgent::Amp + | CLIAgent::Cursor + | CLIAgent::Goose + | CLIAgent::Droid + | CLIAgent::Auggie + | CLIAgent::Hermes + | CLIAgent::Vibe + | CLIAgent::Antigravity + | CLIAgent::Qwen => None, + } + } + pub fn slug(self) -> &'static str { match self { HookAgent::Claude => "claude", @@ -280,10 +307,19 @@ pub struct HookTarget<'a> { impl<'a> HookTarget<'a> { pub fn local(host: &'a dyn Host) -> Option> { + Self::local_for_exe(host, std::env::current_exe().ok()?) + } + + /// Build a local target for a known tty7 hook runner. + /// + /// Most callers use [`Self::local`]. The standalone CLI is the exception: + /// it diagnoses hooks but never executes them, so it supplies the + /// `tty7-app` it would launch instead of comparing configs to `tty7`. + pub fn local_for_exe(host: &'a dyn Host, exe: PathBuf) -> Option> { Some(HookTarget { host, home: home_dir()?, - exe: std::env::current_exe().ok()?, + exe, }) } @@ -929,6 +965,21 @@ export default function (pi: ExtensionAPI) {{ mod tests { use super::*; + /// The exhaustive match keeps every detected agent mapped; this keeps the + /// other direction honest, so a hooked agent cannot become unreachable + /// from detection and silently stop being diagnosed. + #[test] + fn every_hooked_agent_is_reachable_from_detection() { + for hooked in HookAgent::ALL { + assert!( + CLIAgent::ALL + .into_iter() + .any(|detected| HookAgent::of_detected(detected) == Some(hooked)), + "{hooked:?} has hooks but no detected agent maps to it" + ); + } + } + #[test] fn every_tty7_daemon_host_takes_the_console_fast_path() { for name in ["tty7-app.exe", "tty7-server.exe", "tty7.exe"] {