Merge pull request #127 from l0ng-ai/feat/agent-hook-cwd

feat(agent-hooks): follow the agent's own cwd for the sidebar git line
This commit is contained in:
l0ng-ai
2026-07-17 18:11:12 +08:00
committed by GitHub
6 changed files with 78 additions and 2 deletions
+2 -1
View File
@@ -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())
@@ -979,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");
+61
View File
@@ -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!(
+1
View File
@@ -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!(
+1
View File
@@ -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 }),
+1
View File
@@ -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
View File
@@ -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);
}