diff --git a/src/core/agent_hooks.rs b/src/core/agent_hooks.rs index 68ef7c5a..de31a4f9 100644 --- a/src/core/agent_hooks.rs +++ b/src/core/agent_hooks.rs @@ -110,7 +110,7 @@ fn build_hook_sequence(agent: &str, event: &str, stdin_json: &str) -> Vec { "agent": agent, "event": event, }); - for key in ["session_id", "message"] { + for key in ["session_id", "message", "cwd"] { if let Some(v) = payload .get(key) .and_then(|v| v.as_str()) @@ -432,7 +432,9 @@ pub fn hooks_state(agent: HookAgent) -> HooksState { /// Install (or rewrite in place) one agent's tty7 hooks. Idempotent: existing /// tty7 entries/files are replaced, never duplicated, and anything -/// user-authored is left untouched. Returns a short human-readable summary. +/// user-authored is left untouched. Returns a terse summary meant for the +/// settings row's note line — the row already shows the agent and target +/// path, so the summary never repeats them. pub fn install_hooks(agent: HookAgent) -> anyhow::Result { let path = agent .target_path() @@ -440,10 +442,7 @@ pub fn install_hooks(agent: HookAgent) -> anyhow::Result { match agent { HookAgent::Claude => { hook_map_install(&path, agent, CLAUDE_HOOK_EVENTS)?; - Ok(format!( - "Claude Code hooks installed in {} — restart running claude sessions to pick them up", - path.display() - )) + Ok("Installed".to_string()) } HookAgent::Codex => { hook_map_install(&path, agent, CODEX_HOOK_EVENTS)?; @@ -451,11 +450,10 @@ pub fn install_hooks(agent: HookAgent) -> anyhow::Result { // Best-effort: the file install above is complete and correct // either way, so a missing codex binary downgrades to advice // instead of failing the install. - let summary = format!("Codex hooks installed in {}", path.display()); Ok(match enable_codex_hooks_feature() { - Ok(()) => summary, + Ok(()) => "Installed".to_string(), Err(e) => format!( - "{summary} — couldn't run `codex features enable hooks` ({e}); run it once manually" + "Installed, but couldn't run `codex features enable hooks` ({e}) — run it once manually" ), }) } @@ -463,11 +461,7 @@ pub fn install_hooks(agent: HookAgent) -> anyhow::Result { let content = owned_file_content(agent) .ok_or_else(|| anyhow::anyhow!("cannot resolve tty7's own executable path"))?; owned_file_install(&path, &content, &agent.marker())?; - Ok(format!( - "{} integration installed at {}", - agent.display_name(), - path.display() - )) + Ok("Installed".to_string()) } } } @@ -505,7 +499,11 @@ pub fn refresh_hooks_at_launch() -> usize { match install_hooks(agent) { Ok(summary) => { refreshed += 1; - log::info!("refreshed stale agent hooks: {summary}"); + log::info!( + "refreshed stale {} hooks at {}: {summary}", + agent.display_name(), + agent.target_display() + ); } Err(e) => log::warn!( "could not refresh stale {} hooks: {e}", @@ -733,11 +731,7 @@ fn hook_map_uninstall(path: &Path, agent: HookAgent) -> anyhow::Result { return Ok("No tty7 hooks found; nothing to remove".to_string()); } crate::core::config::write_atomic(path, serde_json::to_string_pretty(&root)?.as_bytes())?; - Ok(format!( - "{} hooks removed from {}", - agent.display_name(), - path.display() - )) + Ok("Removed".to_string()) } /// The tty7 hook command inside one matcher entry @@ -856,7 +850,7 @@ fn owned_file_uninstall(path: &Path, marker: &str) -> anyhow::Result { { let _ = std::fs::remove_dir(parent); } - Ok(format!("Removed {}", path.display())) + Ok("Removed".to_string()) } /// Copilot hook file (`~/.copilot/hooks/tty7.json`): Copilot auto-loads every @@ -985,6 +979,7 @@ mod tests { assert_eq!(ev.kind, AgentEventKind::Notification); assert_eq!(ev.session_id.as_deref(), Some("abc-123")); assert!(ev.message.as_deref().unwrap().contains("permission")); + assert_eq!(ev.cwd.as_deref(), Some(std::path::Path::new("/w"))); // Garbage stdin still yields a well-formed bare event. let seq = build_hook_sequence("claude", "stop", "not json at all"); diff --git a/src/core/cli_agent.rs b/src/core/cli_agent.rs index d411fa12..eb6a2964 100644 --- a/src/core/cli_agent.rs +++ b/src/core/cli_agent.rs @@ -485,6 +485,13 @@ pub struct AgentSessionState { /// agent's own notification text was already toasted by the client). #[serde(default)] pub rich: bool, + /// The agent's working directory as its hook payloads report it — the + /// agent's own claim, which tracks internal chdirs the PTY can't show + /// (Claude Code's EnterWorktree moves the session without any shell `cd`). + /// Cleared on `session-end` so a finished session can't pin consumers to + /// a stale path; while absent, consumers fall back to the pane's proc cwd. + #[serde(default)] + pub cwd: Option, } impl AgentStatus { @@ -512,6 +519,9 @@ impl AgentSessionState { if let Some(id) = &ev.session_id { self.session_id = Some(id.clone()); } + if let Some(cwd) = &ev.cwd { + self.cwd = Some(cwd.clone()); + } match ev.kind { AgentEventKind::SessionStart => { self.status = AgentStatus::Idle; @@ -560,9 +570,12 @@ impl AgentSessionState { } // The agent session ended but its id stays: Claude & friends can // resume an *ended* session, which is exactly what restore does. + // Its cwd claim does NOT stay: with no agent running, the pane's + // real (proc-observed) directory is the truth again. AgentEventKind::SessionEnd => { self.status = AgentStatus::Idle; self.message = None; + self.cwd = None; } } } @@ -596,6 +609,9 @@ pub struct AgentEvent { pub kind: AgentEventKind, pub session_id: Option, pub message: Option, + /// The agent's working directory at the moment the hook fired, when the + /// payload carries one (Claude Code sends it on every hook event). + pub cwd: Option, } /// Parse a complete OSC payload (identifier included, e.g. @@ -621,6 +637,8 @@ pub fn parse_agent_event(payload: &[u8]) -> Option { session_id: Option, #[serde(default)] message: Option, + #[serde(default)] + cwd: Option, } let w: Wire = serde_json::from_slice(json).ok()?; @@ -631,6 +649,7 @@ pub fn parse_agent_event(payload: &[u8]) -> Option { kind, session_id: nonempty(w.session_id), message: nonempty(w.message), + cwd: nonempty(w.cwd).map(std::path::PathBuf::from), }) } @@ -880,6 +899,7 @@ mod tests { kind, session_id: id.map(String::from), message: msg.map(String::from), + cwd: None, }; s.apply_event(&ev(AgentEventKind::SessionStart, None, Some("sid-1"))); @@ -939,6 +959,47 @@ mod tests { assert_eq!(s.session_id.as_deref(), Some("sid-1")); } + /// The agent's cwd claim: any event carrying one sets it, later events + /// without one leave it alone (mid-turn events keep the worktree path + /// alive), and session end drops it — an exited agent must not pin the + /// pane's git line to a directory nothing runs in anymore. + #[test] + fn session_state_tracks_and_releases_the_agent_cwd() { + use std::path::PathBuf; + + let ev = |kind, cwd: Option<&str>| AgentEvent { + agent: Some(CLIAgent::Claude), + kind, + session_id: None, + message: None, + cwd: cwd.map(PathBuf::from), + }; + + let mut s = AgentSessionState::default(); + s.apply_event(&ev(AgentEventKind::SessionStart, Some("/repo"))); + assert_eq!(s.cwd.as_deref(), Some(std::path::Path::new("/repo"))); + + // EnterWorktree lands as a tool-complete carrying the new directory. + s.apply_event(&ev( + AgentEventKind::ToolComplete, + Some("/repo/.claude/worktrees/fix-x"), + )); + assert_eq!( + s.cwd.as_deref(), + Some(std::path::Path::new("/repo/.claude/worktrees/fix-x")) + ); + + // An event without a cwd (another agent's sparser payload) keeps it. + s.apply_event(&ev(AgentEventKind::Stop, None)); + assert_eq!( + s.cwd.as_deref(), + Some(std::path::Path::new("/repo/.claude/worktrees/fix-x")) + ); + + s.apply_event(&ev(AgentEventKind::SessionEnd, None)); + assert_eq!(s.cwd, None, "session end releases the cwd claim"); + } + #[test] fn resume_commands_are_shell_safe() { assert_eq!( diff --git a/src/daemon/pane.rs b/src/daemon/pane.rs index da802527..4072e27a 100644 --- a/src/daemon/pane.rs +++ b/src/daemon/pane.rs @@ -2980,6 +2980,7 @@ mod tests { message: None, session_id: Some("sid".into()), rich: true, + cwd: None, }); apply_signals(&mut st, sniffer.feed(b"\x1b]9;noise\x07")); assert_eq!( diff --git a/src/daemon/protocol.rs b/src/daemon/protocol.rs index fc99bcf0..7cddf932 100644 --- a/src/daemon/protocol.rs +++ b/src/daemon/protocol.rs @@ -1540,6 +1540,7 @@ mod tests { message: Some("Claude needs your permission to use Bash".into()), session_id: Some("abc-123".into()), rich: true, + cwd: Some("/repo/.claude/worktrees/fix-x".into()), })), DaemonMsg::AgentStatus(None), DaemonMsg::LoopbackForward(LoopbackForward { local_port: 49152 }), diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index cf5500bb..0031248c 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -2329,6 +2329,7 @@ mod tests { message: Some("Claude needs your permission".into()), session_id: Some("sid-1".into()), rich: true, + cwd: None, })) .encode(&mut daemon_side) .unwrap(); diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 8a62949f..7bf44e45 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -2573,7 +2573,18 @@ impl TerminalView { // an agent's session is one long foreground command, so its edits would // otherwise stay invisible until it exits. All rare edges, so the // off-thread `git` shell-out runs seldom, not every 300ms tick. - let cwd_now = self.cwd(); + // + // An agent that reports its own cwd through the hook channel wins over + // the proc probe: it tracks internal chdirs the PTY can't observe + // (Claude Code's EnterWorktree) and works where the proc fallback + // doesn't (Windows). The claim dies with the session (`session-end` + // clears it, and the agent leaving the foreground drops the whole + // state), so an exited agent falls back to the pane's real directory. + let cwd_now = self + .terminal + .agent_session() + .and_then(|s| s.cwd) + .or_else(|| self.cwd()); if cwd_now.as_ref() != self.git_status_cwd.as_ref() || cmd_finished || turn_finished { self.refresh_git_status(cwd_now, cx); }