anvilsign in

collin/browser-terminal-extension

1//! Claude Code session state, published by Claude Code's own hook interface.
2//!
3//! Claude runs `termbridge hook` on the events it documents, each of which
4//! arrives as JSON on stdin. We keep one record per Claude session under
5//! `<config>/agents/<session_id>.json` and update it in place. The daemon reads
6//! those records; nothing here parses a terminal screen, so a UI change in
7//! Claude cannot silently turn the status wrong.
8//!
9//! The link back to tmux is `$TMUX_PANE`, which Claude's process inherits and
10//! passes on to the hook. It is the pane id (`%3`) and is what lets the sidebar
11//! say *which* pane is waiting on you.
12
13use std::collections::HashMap;
14use std::fs;
15use std::io;
16use std::path::PathBuf;
17
18use serde::{Deserialize, Serialize};
19
20/// What a Claude session is doing. Derived from the hook event, never guessed.
21#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
22#[serde(rename_all = "lowercase")]
23pub enum State {
24 /// Mid-turn: thinking, or running a tool.
25 Working,
26 /// Blocked on the user — a permission prompt or an elicitation dialog.
27 Waiting,
28 /// Turn finished, prompt is yours.
29 Idle,
30}
31
32impl State {
33 pub fn as_str(self) -> &'static str {
34 match self {
35 State::Working => "working",
36 State::Waiting => "waiting",
37 State::Idle => "idle",
38 }
39 }
40}
41
42#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
43pub struct Record {
44 pub session_id: String,
45 /// tmux pane id (`%3`) from `$TMUX_PANE`. Absent when Claude runs outside
46 /// tmux, in which case the sidebar has nothing to attach the status to.
47 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub pane: Option<String>,
49 pub cwd: String,
50 pub state: State,
51 /// `permission_mode` as Claude reports it: `default`, `acceptEdits`,
52 /// `plan`, `bypassPermissions`. Carried forward across events that don't
53 /// include it (`Notification` is the one that matters — the mode must not
54 /// blink out exactly when you're being asked to approve something).
55 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub mode: Option<String>,
57 /// Tool currently running, from `PreToolUse`.
58 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub tool: Option<String>,
60 /// Why it is waiting, from the `Notification` message.
61 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub message: Option<String>,
63 /// Session title, when Claude has one.
64 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub name: Option<String>,
66 /// Which asterisk frame this session's glyph is on. Advanced once per hook
67 /// event so the tmux status line has something to animate with — see
68 /// [`crate::window_status`]. The sidebar runs its own timer and ignores it.
69 #[serde(default)]
70 pub frame: u8,
71 /// Unix seconds of the last event. Lets a reader spot a session whose
72 /// process died without a `SessionEnd`.
73 pub updated: u64,
74}
75
76pub fn dir() -> PathBuf {
77 crate::paths::config_dir().join("agents")
78}
79
80/// Every record on disk, newest first. Unreadable or malformed files are
81/// skipped: a status panel is not worth failing a connection over.
82pub fn read_all() -> Vec<Record> {
83 let Ok(entries) = fs::read_dir(dir()) else {
84 return Vec::new();
85 };
86 let mut out: Vec<Record> = entries
87 .flatten()
88 .filter(|e| e.path().extension().is_some_and(|x| x == "json"))
89 .filter_map(|e| fs::read_to_string(e.path()).ok())
90 .filter_map(|s| serde_json::from_str(&s).ok())
91 .collect();
92 out.sort_by_key(|r| std::cmp::Reverse(r.updated));
93 out
94}
95
96/// Newest record per pane. Panes get reused across sessions, so "newest wins"
97/// is what keeps a finished session from shadowing the live one.
98pub fn by_pane() -> HashMap<String, Record> {
99 let mut map = HashMap::new();
100 for r in read_all() {
101 if let Some(pane) = r.pane.clone() {
102 map.entry(pane).or_insert(r);
103 }
104 }
105 map
106}
107
108fn now() -> u64 {
109 std::time::SystemTime::now()
110 .duration_since(std::time::UNIX_EPOCH)
111 .map(|d| d.as_secs())
112 .unwrap_or(0)
113}
114
115/// Apply one hook event. Returns the new record, or `None` when the event ends
116/// the session and the record was removed.
117///
118/// Unknown events are ignored rather than rejected — Claude Code gains hook
119/// events over time and an unrecognised one is not an error.
120pub fn apply(event: &serde_json::Value) -> io::Result<Option<Record>> {
121 let Some(session_id) = event.get("session_id").and_then(|v| v.as_str()) else {
122 return Ok(None);
123 };
124 let path = dir().join(format!("{}.json", sanitize_id(session_id)));
125 let hook = event
126 .get("hook_event_name")
127 .and_then(|v| v.as_str())
128 .unwrap_or("");
129
130 if hook == "SessionEnd" {
131 let _ = fs::remove_file(&path);
132 return Ok(None);
133 }
134
135 let str_field = |k: &str| event.get(k).and_then(|v| v.as_str()).map(str::to_string);
136 let notification_type = str_field("notification_type");
137
138 let state = match hook {
139 "UserPromptSubmit" | "PreToolUse" | "PostToolUse" | "PostToolUseFailure" => State::Working,
140 "Stop" | "SessionStart" | "SubagentStop" => State::Idle,
141 // Claude fires this both to ask for something and to say it has been
142 // idle a while; only the first means it is blocked on you.
143 "Notification" => match notification_type.as_deref() {
144 Some("idle_prompt") => State::Idle,
145 _ => State::Waiting,
146 },
147 "PermissionRequest" => State::Waiting,
148 _ => return Ok(None),
149 };
150
151 let previous: Option<Record> = fs::read_to_string(&path)
152 .ok()
153 .and_then(|s| serde_json::from_str(&s).ok());
154 let carry = |field: fn(&Record) -> Option<String>| previous.as_ref().and_then(field);
155
156 let record = Record {
157 session_id: session_id.to_string(),
158 // TMUX_PANE comes from the environment Claude was launched in, which
159 // the hook process inherits.
160 pane: std::env::var("TMUX_PANE")
161 .ok()
162 .or_else(|| carry(|r| r.pane.clone())),
163 cwd: str_field("cwd")
164 .or_else(|| previous.as_ref().map(|r| r.cwd.clone()))
165 .unwrap_or_default(),
166 state,
167 mode: str_field("permission_mode").or_else(|| carry(|r| r.mode.clone())),
168 tool: match hook {
169 "PreToolUse" => str_field("tool_name"),
170 _ => None,
171 },
172 message: match state {
173 State::Waiting => str_field("message"),
174 _ => None,
175 },
176 name: str_field("session_name").or_else(|| carry(|r| r.name.clone())),
177 // Wrapping, and the glyph indexes into a six-frame cycle: the absolute
178 // count is never read, only its position in the loop.
179 frame: previous.as_ref().map_or(0, |p| p.frame.wrapping_add(1)),
180 updated: now(),
181 };
182
183 write(&path, &record)?;
184 Ok(Some(record))
185}
186
187fn write(path: &std::path::Path, record: &Record) -> io::Result<()> {
188 let dir = path.parent().expect("record path has a parent");
189 fs::create_dir_all(dir)?;
190 let json = serde_json::to_string(record).map_err(io::Error::other)?;
191 // Write-then-rename: the daemon polls this directory and must never read a
192 // half-written file.
193 let tmp = path.with_extension("tmp");
194 fs::write(&tmp, json)?;
195 fs::rename(&tmp, path)
196}
197
198/// Session ids come from Claude, but they end up in a filename, so treat them
199/// as untrusted: no separators, no `..`, no surprises.
200fn sanitize_id(id: &str) -> String {
201 id.chars()
202 .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
203 .take(128)
204 .collect()
205}
206
207/// The settings block a user pastes into `~/.claude/settings.json`.
208pub fn hook_settings(exe: &str) -> String {
209 let events = [
210 ("SessionStart", ""),
211 ("UserPromptSubmit", ""),
212 ("PreToolUse", "*"),
213 ("PostToolUse", "*"),
214 ("Notification", ""),
215 ("Stop", ""),
216 ("SessionEnd", ""),
217 ];
218 let blocks: Vec<String> = events
219 .iter()
220 .map(|(event, matcher)| {
221 let matcher = if matcher.is_empty() {
222 String::new()
223 } else {
224 format!("\"matcher\": \"{matcher}\", ")
225 };
226 format!(
227 " \"{event}\": [\n {{ {matcher}\"hooks\": [{{ \"type\": \"command\", \"command\": \"{exe} hook\" }}] }}\n ]"
228 )
229 })
230 .collect();
231 format!("{{\n \"hooks\": {{\n{}\n }}\n}}", blocks.join(",\n"))
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 /// Each test gets its own config dir; `apply` writes to a real path. The
239 /// lock is load-bearing: the dir is selected by an env var, which is
240 /// process-wide, and cargo runs these tests on threads.
241 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
242
243 fn with_temp_dir(f: impl FnOnce()) {
244 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
245 let tmp = tempfile::tempdir().unwrap();
246 // SAFETY: single-threaded test, and the var is read through
247 // paths::config_dir() only.
248 unsafe { std::env::set_var("TERMBRIDGE_CONFIG_DIR", tmp.path()) };
249 f();
250 unsafe { std::env::remove_var("TERMBRIDGE_CONFIG_DIR") };
251 }
252
253 fn event(json: serde_json::Value) -> Option<Record> {
254 apply(&json).unwrap()
255 }
256
257 #[test]
258 fn tracks_the_documented_lifecycle() {
259 with_temp_dir(|| {
260 let r = event(serde_json::json!({
261 "session_id": "abc", "hook_event_name": "UserPromptSubmit",
262 "cwd": "/tmp/p", "permission_mode": "acceptEdits",
263 }))
264 .unwrap();
265 assert_eq!(r.state, State::Working);
266 assert_eq!(r.mode.as_deref(), Some("acceptEdits"));
267
268 let r = event(serde_json::json!({
269 "session_id": "abc", "hook_event_name": "PreToolUse",
270 "cwd": "/tmp/p", "permission_mode": "acceptEdits", "tool_name": "Bash",
271 }))
272 .unwrap();
273 assert_eq!(r.tool.as_deref(), Some("Bash"));
274
275 let r = event(serde_json::json!({
276 "session_id": "abc", "hook_event_name": "Stop", "cwd": "/tmp/p",
277 }))
278 .unwrap();
279 assert_eq!(r.state, State::Idle);
280 assert_eq!(r.tool, None);
281 });
282 }
283
284 #[test]
285 fn notification_distinguishes_asking_from_idling() {
286 with_temp_dir(|| {
287 let asking = event(serde_json::json!({
288 "session_id": "a", "hook_event_name": "Notification",
289 "notification_type": "permission_prompt", "message": "Bash needs approval",
290 }))
291 .unwrap();
292 assert_eq!(asking.state, State::Waiting);
293 assert_eq!(asking.message.as_deref(), Some("Bash needs approval"));
294
295 let idling = event(serde_json::json!({
296 "session_id": "b", "hook_event_name": "Notification",
297 "notification_type": "idle_prompt",
298 }))
299 .unwrap();
300 assert_eq!(idling.state, State::Idle);
301 });
302 }
303
304 /// Notification carries no `permission_mode`, and that is exactly when the
305 /// user wants to see it.
306 #[test]
307 fn mode_survives_events_that_omit_it() {
308 with_temp_dir(|| {
309 event(serde_json::json!({
310 "session_id": "a", "hook_event_name": "UserPromptSubmit",
311 "permission_mode": "plan", "cwd": "/tmp/p",
312 }));
313 let r = event(serde_json::json!({
314 "session_id": "a", "hook_event_name": "Notification",
315 "notification_type": "permission_prompt",
316 }))
317 .unwrap();
318 assert_eq!(r.mode.as_deref(), Some("plan"));
319 assert_eq!(r.cwd, "/tmp/p");
320 });
321 }
322
323 #[test]
324 fn session_end_removes_the_record() {
325 with_temp_dir(|| {
326 event(serde_json::json!({
327 "session_id": "a", "hook_event_name": "SessionStart", "cwd": "/tmp/p",
328 }));
329 assert_eq!(read_all().len(), 1);
330 assert!(event(serde_json::json!({
331 "session_id": "a", "hook_event_name": "SessionEnd", "reason": "clear",
332 }))
333 .is_none());
334 assert!(read_all().is_empty());
335 });
336 }
337
338 #[test]
339 fn unknown_events_and_ids_are_ignored() {
340 with_temp_dir(|| {
341 assert!(event(serde_json::json!({"hook_event_name": "Stop"})).is_none());
342 assert!(event(serde_json::json!({"session_id": "a", "hook_event_name": "Wat"})).is_none());
343 assert!(read_all().is_empty());
344 });
345 }
346
347 #[test]
348 fn session_ids_cannot_escape_the_directory() {
349 assert_eq!(sanitize_id("../../etc/passwd"), "etcpasswd");
350 assert_eq!(sanitize_id("abc-123_XY"), "abc-123_XY");
351 }
352}