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
76impl Record {
77 /// Seconds since the last hook event. Saturating, so a record written
78 /// before a clock adjustment reads as brand new rather than ancient.
79 pub fn age(&self) -> u64 {
80 now().saturating_sub(self.updated)
81 }
82}
83
84pub fn dir() -> PathBuf {
85 crate::paths::config_dir().join("agents")
86}
87
88/// Every record on disk, newest first. Unreadable or malformed files are
89/// skipped: a status panel is not worth failing a connection over.
90pub fn read_all() -> Vec<Record> {
91 let Ok(entries) = fs::read_dir(dir()) else {
92 return Vec::new();
93 };
94 let mut out: Vec<Record> = entries
95 .flatten()
96 .filter(|e| e.path().extension().is_some_and(|x| x == "json"))
97 .filter_map(|e| fs::read_to_string(e.path()).ok())
98 .filter_map(|s| serde_json::from_str(&s).ok())
99 .collect();
100 out.sort_by_key(|r| std::cmp::Reverse(r.updated));
101 out
102}
103
104/// Newest record per pane. Panes get reused across sessions, so "newest wins"
105/// is what keeps a finished session from shadowing the live one.
106pub fn by_pane() -> HashMap<String, Record> {
107 let mut map = HashMap::new();
108 for r in read_all() {
109 if let Some(pane) = r.pane.clone() {
110 map.entry(pane).or_insert(r);
111 }
112 }
113 map
114}
115
116pub fn now() -> u64 {
117 std::time::SystemTime::now()
118 .duration_since(std::time::UNIX_EPOCH)
119 .map(|d| d.as_secs())
120 .unwrap_or(0)
121}
122
123/// Apply one hook event. Returns the new record, or `None` when the event ends
124/// the session and the record was removed.
125///
126/// Unknown events are ignored rather than rejected — Claude Code gains hook
127/// events over time and an unrecognised one is not an error.
128pub fn apply(event: &serde_json::Value) -> io::Result<Option<Record>> {
129 let Some(session_id) = event.get("session_id").and_then(|v| v.as_str()) else {
130 return Ok(None);
131 };
132 let path = dir().join(format!("{}.json", sanitize_id(session_id)));
133 let hook = event
134 .get("hook_event_name")
135 .and_then(|v| v.as_str())
136 .unwrap_or("");
137
138 if hook == "SessionEnd" {
139 let _ = fs::remove_file(&path);
140 return Ok(None);
141 }
142
143 let str_field = |k: &str| event.get(k).and_then(|v| v.as_str()).map(str::to_string);
144 let notification_type = str_field("notification_type");
145
146 let state = match hook {
147 "UserPromptSubmit" | "PreToolUse" | "PostToolUse" | "PostToolUseFailure" => State::Working,
148 "Stop" | "SessionStart" | "SubagentStop" => State::Idle,
149 // Claude fires this both to ask for something and to say it has been
150 // idle a while; only the first means it is blocked on you.
151 "Notification" => match notification_type.as_deref() {
152 Some("idle_prompt") => State::Idle,
153 _ => State::Waiting,
154 },
155 "PermissionRequest" => State::Waiting,
156 _ => return Ok(None),
157 };
158
159 let previous: Option<Record> = fs::read_to_string(&path)
160 .ok()
161 .and_then(|s| serde_json::from_str(&s).ok());
162 let carry = |field: fn(&Record) -> Option<String>| previous.as_ref().and_then(field);
163
164 let record = Record {
165 session_id: session_id.to_string(),
166 // TMUX_PANE comes from the environment Claude was launched in, which
167 // the hook process inherits.
168 pane: std::env::var("TMUX_PANE")
169 .ok()
170 .or_else(|| carry(|r| r.pane.clone())),
171 cwd: str_field("cwd")
172 .or_else(|| previous.as_ref().map(|r| r.cwd.clone()))
173 .unwrap_or_default(),
174 state,
175 mode: str_field("permission_mode").or_else(|| carry(|r| r.mode.clone())),
176 tool: match hook {
177 "PreToolUse" => str_field("tool_name"),
178 _ => None,
179 },
180 message: match state {
181 State::Waiting => str_field("message"),
182 _ => None,
183 },
184 name: str_field("session_name").or_else(|| carry(|r| r.name.clone())),
185 // Wrapping, and the glyph indexes into a six-frame cycle: the absolute
186 // count is never read, only its position in the loop.
187 frame: previous.as_ref().map_or(0, |p| p.frame.wrapping_add(1)),
188 updated: now(),
189 };
190
191 write(&path, &record)?;
192 Ok(Some(record))
193}
194
195fn write(path: &std::path::Path, record: &Record) -> io::Result<()> {
196 let dir = path.parent().expect("record path has a parent");
197 fs::create_dir_all(dir)?;
198 let json = serde_json::to_string(record).map_err(io::Error::other)?;
199 // Write-then-rename: the daemon polls this directory and must never read a
200 // half-written file.
201 let tmp = path.with_extension("tmp");
202 fs::write(&tmp, json)?;
203 fs::rename(&tmp, path)
204}
205
206/// Session ids come from Claude, but they end up in a filename, so treat them
207/// as untrusted: no separators, no `..`, no surprises.
208fn sanitize_id(id: &str) -> String {
209 id.chars()
210 .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
211 .take(128)
212 .collect()
213}
214
215/// The settings block a user pastes into `~/.claude/settings.json`.
216pub fn hook_settings(exe: &str) -> String {
217 let events = [
218 ("SessionStart", ""),
219 ("UserPromptSubmit", ""),
220 ("PreToolUse", "*"),
221 ("PostToolUse", "*"),
222 ("Notification", ""),
223 ("Stop", ""),
224 ("SessionEnd", ""),
225 ];
226 let blocks: Vec<String> = events
227 .iter()
228 .map(|(event, matcher)| {
229 let matcher = if matcher.is_empty() {
230 String::new()
231 } else {
232 format!("\"matcher\": \"{matcher}\", ")
233 };
234 format!(
235 " \"{event}\": [\n {{ {matcher}\"hooks\": [{{ \"type\": \"command\", \"command\": \"{exe} hook\" }}] }}\n ]"
236 )
237 })
238 .collect();
239 format!("{{\n \"hooks\": {{\n{}\n }}\n}}", blocks.join(",\n"))
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245
246 /// Each test gets its own config dir; `apply` writes to a real path. The
247 /// lock is load-bearing: the dir is selected by an env var, which is
248 /// process-wide, and cargo runs these tests on threads.
249 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
250
251 fn with_temp_dir(f: impl FnOnce()) {
252 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
253 let tmp = tempfile::tempdir().unwrap();
254 // SAFETY: single-threaded test, and the var is read through
255 // paths::config_dir() only.
256 unsafe { std::env::set_var("TERMBRIDGE_CONFIG_DIR", tmp.path()) };
257 f();
258 unsafe { std::env::remove_var("TERMBRIDGE_CONFIG_DIR") };
259 }
260
261 fn event(json: serde_json::Value) -> Option<Record> {
262 apply(&json).unwrap()
263 }
264
265 #[test]
266 fn tracks_the_documented_lifecycle() {
267 with_temp_dir(|| {
268 let r = event(serde_json::json!({
269 "session_id": "abc", "hook_event_name": "UserPromptSubmit",
270 "cwd": "/tmp/p", "permission_mode": "acceptEdits",
271 }))
272 .unwrap();
273 assert_eq!(r.state, State::Working);
274 assert_eq!(r.mode.as_deref(), Some("acceptEdits"));
275
276 let r = event(serde_json::json!({
277 "session_id": "abc", "hook_event_name": "PreToolUse",
278 "cwd": "/tmp/p", "permission_mode": "acceptEdits", "tool_name": "Bash",
279 }))
280 .unwrap();
281 assert_eq!(r.tool.as_deref(), Some("Bash"));
282
283 let r = event(serde_json::json!({
284 "session_id": "abc", "hook_event_name": "Stop", "cwd": "/tmp/p",
285 }))
286 .unwrap();
287 assert_eq!(r.state, State::Idle);
288 assert_eq!(r.tool, None);
289 });
290 }
291
292 #[test]
293 fn notification_distinguishes_asking_from_idling() {
294 with_temp_dir(|| {
295 let asking = event(serde_json::json!({
296 "session_id": "a", "hook_event_name": "Notification",
297 "notification_type": "permission_prompt", "message": "Bash needs approval",
298 }))
299 .unwrap();
300 assert_eq!(asking.state, State::Waiting);
301 assert_eq!(asking.message.as_deref(), Some("Bash needs approval"));
302
303 let idling = event(serde_json::json!({
304 "session_id": "b", "hook_event_name": "Notification",
305 "notification_type": "idle_prompt",
306 }))
307 .unwrap();
308 assert_eq!(idling.state, State::Idle);
309 });
310 }
311
312 /// Notification carries no `permission_mode`, and that is exactly when the
313 /// user wants to see it.
314 #[test]
315 fn mode_survives_events_that_omit_it() {
316 with_temp_dir(|| {
317 event(serde_json::json!({
318 "session_id": "a", "hook_event_name": "UserPromptSubmit",
319 "permission_mode": "plan", "cwd": "/tmp/p",
320 }));
321 let r = event(serde_json::json!({
322 "session_id": "a", "hook_event_name": "Notification",
323 "notification_type": "permission_prompt",
324 }))
325 .unwrap();
326 assert_eq!(r.mode.as_deref(), Some("plan"));
327 assert_eq!(r.cwd, "/tmp/p");
328 });
329 }
330
331 #[test]
332 fn session_end_removes_the_record() {
333 with_temp_dir(|| {
334 event(serde_json::json!({
335 "session_id": "a", "hook_event_name": "SessionStart", "cwd": "/tmp/p",
336 }));
337 assert_eq!(read_all().len(), 1);
338 assert!(
339 event(serde_json::json!({
340 "session_id": "a", "hook_event_name": "SessionEnd", "reason": "clear",
341 }))
342 .is_none()
343 );
344 assert!(read_all().is_empty());
345 });
346 }
347
348 #[test]
349 fn unknown_events_and_ids_are_ignored() {
350 with_temp_dir(|| {
351 assert!(event(serde_json::json!({"hook_event_name": "Stop"})).is_none());
352 assert!(
353 event(serde_json::json!({"session_id": "a", "hook_event_name": "Wat"})).is_none()
354 );
355 assert!(read_all().is_empty());
356 });
357 }
358
359 #[test]
360 fn session_ids_cannot_escape_the_directory() {
361 assert_eq!(sanitize_id("../../etc/passwd"), "etcpasswd");
362 assert_eq!(sanitize_id("abc-123_XY"), "abc-123_XY");
363 }
364}