fix(cli): diagnose unavailable agent hooks (#321)

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
This commit is contained in:
l0ng-ai
2026-08-04 07:23:20 +08:00
committed by GitHub
co-authored by l0ng-ai
parent 7c9a2d20b6
commit a89d9ea45b
5 changed files with 329 additions and 11 deletions
+15
View File
@@ -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<PaneProcs>;
/// 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<HooksState>;
/// 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<CaptureSegment>,
pub procs_calls: Vec<u64>,
pub procs_reply: PaneProcs,
pub agent_hooks_states: Vec<(HookAgent, HooksState)>,
pub registry: Vec<PaneInfo>,
pub killed: Vec<u64>,
pub kill_failures: Vec<u64>,
@@ -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<HooksState> {
self.agent_hooks_states
.iter()
.find_map(|(candidate, state)| (*candidate == agent).then_some(*state))
}
fn list_panes(&mut self) -> Result<Vec<PaneInfo>> {
Ok(self.registry.clone())
}
+11
View File
@@ -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<HooksState> {
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<Vec<PaneInfo>> {
let panes = self
.pane_client()?
+246 -8
View File
@@ -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<bool> {
fn agents(backend: &mut dyn Backend) -> Result<Outcome> {
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<HookGap> {
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<AgentHookDiagnostic> {
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<Outcome> {
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 {
+4 -1
View File
@@ -21,7 +21,10 @@ pub fn launch(path: Option<&Path>) -> Result<()> {
Ok(())
}
fn find_executable() -> Result<PathBuf> {
/// 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<PathBuf> {
if let Some(explicit) = std::env::var_os("TTY7_APP") {
let path = PathBuf::from(explicit);
if path.is_file() {
+53 -2
View File
@@ -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<HookAgent> {
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<HookTarget<'a>> {
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<HookTarget<'a>> {
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"] {