mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 08:02:24 +00:00
feat(agent-hooks): follow the agent's own cwd for the sidebar git line
Claude Code's EnterWorktree chdirs the agent without any shell cd, so the sidebar's branch/diff line could not see the move on platforms without the proc-cwd fallback. Forward the cwd field every Claude Code hook payload already carries through the OSC 777 sentinel, keep it on the pane's agent session state, and let it take precedence over the proc probe for the git line. The claim is released on session-end (and when the agent leaves the foreground), so an exited agent falls back to the pane's real directory.
This commit is contained in:
+16
-21
@@ -110,7 +110,7 @@ fn build_hook_sequence(agent: &str, event: &str, stdin_json: &str) -> Vec<u8> {
|
||||
"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<String> {
|
||||
let path = agent
|
||||
.target_path()
|
||||
@@ -440,10 +442,7 @@ pub fn install_hooks(agent: HookAgent) -> anyhow::Result<String> {
|
||||
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<String> {
|
||||
// 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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
{
|
||||
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");
|
||||
|
||||
@@ -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<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
pub message: Option<String>,
|
||||
/// 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<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
/// Parse a complete OSC payload (identifier included, e.g.
|
||||
@@ -621,6 +637,8 @@ pub fn parse_agent_event(payload: &[u8]) -> Option<AgentEvent> {
|
||||
session_id: Option<String>,
|
||||
#[serde(default)]
|
||||
message: Option<String>,
|
||||
#[serde(default)]
|
||||
cwd: Option<String>,
|
||||
}
|
||||
|
||||
let w: Wire = serde_json::from_slice(json).ok()?;
|
||||
@@ -631,6 +649,7 @@ pub fn parse_agent_event(payload: &[u8]) -> Option<AgentEvent> {
|
||||
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!(
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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();
|
||||
|
||||
+12
-1
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user