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 /// The model id, e.g. `claude-opus-4-1-20250805`. Only `SessionStart`
67 /// carries it, and even there it is not guaranteed, so this is carried
68 /// forward across every later event the way `mode` and `name` are; it goes
69 /// stale if the session switches models mid-conversation, but a stale
70 /// answer beats none.
71 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub model: Option<String>,
73 /// One line of the last thing the user asked, from `UserPromptSubmit`. It
74 /// is what [`crate::pane_title`] writes to the pane title, and what the
75 /// sidebar falls back to when a session has no name yet — which is most of
76 /// them, since a name only arrives once Claude has thought of one.
77 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub prompt: Option<String>,
79 /// Which asterisk frame this session's glyph is on. Advanced once per hook
80 /// event so the tmux status line has something to animate with — see
81 /// [`crate::window_status`]. The sidebar runs its own timer and ignores it.
82 #[serde(default)]
83 pub frame: u8,
84 /// Unix seconds of the last event. Lets a reader spot a session whose
85 /// process died without a `SessionEnd`.
86 pub updated: u64,
87}
88
89impl Record {
90 /// Seconds since the last hook event. Saturating, so a record written
91 /// before a clock adjustment reads as brand new rather than ancient.
92 pub fn age(&self) -> u64 {
93 now().saturating_sub(self.updated)
94 }
95}
96
97pub fn dir() -> PathBuf {
98 crate::paths::config_dir().join("agents")
99}
100
101/// Every record on disk, newest first. Unreadable or malformed files are
102/// skipped: a status panel is not worth failing a connection over.
103pub fn read_all() -> Vec<Record> {
104 let Ok(entries) = fs::read_dir(dir()) else {
105 return Vec::new();
106 };
107 let mut out: Vec<Record> = entries
108 .flatten()
109 .filter(|e| e.path().extension().is_some_and(|x| x == "json"))
110 .filter_map(|e| fs::read_to_string(e.path()).ok())
111 .filter_map(|s| serde_json::from_str(&s).ok())
112 .collect();
113 out.sort_by_key(|r| std::cmp::Reverse(r.updated));
114 out
115}
116
117/// Newest record per pane. Panes get reused across sessions, so "newest wins"
118/// is what keeps a finished session from shadowing the live one.
119pub fn by_pane() -> HashMap<String, Record> {
120 let mut map = HashMap::new();
121 for r in read_all() {
122 if let Some(pane) = r.pane.clone() {
123 map.entry(pane).or_insert(r);
124 }
125 }
126 map
127}
128
129pub fn now() -> u64 {
130 std::time::SystemTime::now()
131 .duration_since(std::time::UNIX_EPOCH)
132 .map(|d| d.as_secs())
133 .unwrap_or(0)
134}
135
136/// Apply one hook event. Returns the new record, or `None` when the event ends
137/// the session and the record was removed.
138///
139/// Unknown events are ignored rather than rejected — Claude Code gains hook
140/// events over time and an unrecognised one is not an error.
141pub fn apply(event: &serde_json::Value) -> io::Result<Option<Record>> {
142 let Some(session_id) = event.get("session_id").and_then(|v| v.as_str()) else {
143 return Ok(None);
144 };
145 let path = dir().join(format!("{}.json", sanitize_id(session_id)));
146 let hook = event
147 .get("hook_event_name")
148 .and_then(|v| v.as_str())
149 .unwrap_or("");
150
151 if hook == "SessionEnd" {
152 let _ = fs::remove_file(&path);
153 return Ok(None);
154 }
155
156 let str_field = |k: &str| event.get(k).and_then(|v| v.as_str()).map(str::to_string);
157 let notification_type = str_field("notification_type");
158
159 let state = match hook {
160 "UserPromptSubmit" | "PreToolUse" | "PostToolUse" | "PostToolUseFailure" => State::Working,
161 "Stop" | "SessionStart" | "SubagentStop" => State::Idle,
162 // Claude fires this both to ask for something and to say it has been
163 // idle a while; only the first means it is blocked on you.
164 "Notification" => match notification_type.as_deref() {
165 Some("idle_prompt") => State::Idle,
166 _ => State::Waiting,
167 },
168 "PermissionRequest" => State::Waiting,
169 _ => return Ok(None),
170 };
171
172 let previous: Option<Record> = fs::read_to_string(&path)
173 .ok()
174 .and_then(|s| serde_json::from_str(&s).ok());
175 let carry = |field: fn(&Record) -> Option<String>| previous.as_ref().and_then(field);
176
177 let record = Record {
178 session_id: session_id.to_string(),
179 // TMUX_PANE comes from the environment Claude was launched in, which
180 // the hook process inherits.
181 pane: std::env::var("TMUX_PANE")
182 .ok()
183 .or_else(|| carry(|r| r.pane.clone())),
184 cwd: str_field("cwd")
185 .or_else(|| previous.as_ref().map(|r| r.cwd.clone()))
186 .unwrap_or_default(),
187 state,
188 mode: str_field("permission_mode").or_else(|| carry(|r| r.mode.clone())),
189 tool: match hook {
190 "PreToolUse" => str_field("tool_name"),
191 _ => None,
192 },
193 message: match state {
194 State::Waiting => str_field("message"),
195 _ => None,
196 },
197 name: str_field("session_name").or_else(|| carry(|r| r.name.clone())),
198 model: str_field("model").or_else(|| carry(|r| r.model.clone())),
199 // Only `UserPromptSubmit` carries one; every other event keeps the one
200 // already on disk, so the title stays put for the whole turn instead of
201 // blinking out on the first tool call.
202 prompt: str_field("prompt")
203 .as_deref()
204 .and_then(summarize)
205 .or_else(|| carry(|r| r.prompt.clone())),
206 // Wrapping, and the glyph indexes into a six-frame cycle: the absolute
207 // count is never read, only its position in the loop.
208 frame: previous.as_ref().map_or(0, |p| p.frame.wrapping_add(1)),
209 updated: now(),
210 };
211
212 write(&path, &record)?;
213 Ok(Some(record))
214}
215
216fn write(path: &std::path::Path, record: &Record) -> io::Result<()> {
217 let dir = path.parent().expect("record path has a parent");
218 fs::create_dir_all(dir)?;
219 let json = serde_json::to_string(record).map_err(io::Error::other)?;
220 // Write-then-rename: the daemon polls this directory and must never read a
221 // half-written file.
222 let tmp = path.with_extension("tmp");
223 fs::write(&tmp, json)?;
224 fs::rename(&tmp, path)
225}
226
227/// How much of a prompt is kept. Long enough that a sentence usually survives
228/// whole, short enough that a record, a pane title and a status line entry are
229/// all bounded. Anything that has to be shorter still — a narrow status line,
230/// a sidebar tab — trims what it renders rather than what is stored.
231const PROMPT_MAX: usize = 80;
232
233/// A prompt as a single line of title.
234///
235/// Prompts are pasted stack traces and multi-paragraph essays as often as they
236/// are one sentence, and they reach a pane title, so they are flattened to one
237/// line, stripped of control characters, and cut. `None` for a prompt with
238/// nothing printable in it — better to keep the previous title than to blank it.
239pub fn summarize(prompt: &str) -> Option<String> {
240 let mut out = String::new();
241 let mut space = false;
242 for c in prompt.chars() {
243 if c.is_whitespace() {
244 space = !out.is_empty();
245 continue;
246 }
247 // Control characters, and the escape sequences built from them, would
248 // be written straight into a terminal's title.
249 if c.is_control() {
250 continue;
251 }
252 if space {
253 out.push(' ');
254 space = false;
255 }
256 out.push(c);
257 // One past the limit, so the caller below can tell "exactly full" from
258 // "there was more".
259 if out.chars().count() > PROMPT_MAX {
260 break;
261 }
262 }
263 if out.is_empty() {
264 return None;
265 }
266 if out.chars().count() <= PROMPT_MAX {
267 return Some(out);
268 }
269 let mut cut: String = out.chars().take(PROMPT_MAX).collect();
270 // Prefer a word boundary, but only a late one: cutting "refactor the
271 // authentication" back to "refactor" loses more than the ragged edge costs.
272 let floor = cut.len() * 2 / 3;
273 if let Some(i) = cut.rfind(' ').filter(|i| *i >= floor) {
274 cut.truncate(i);
275 }
276 cut.push('…');
277 Some(cut)
278}
279
280/// Session ids come from Claude, but they end up in a filename, so treat them
281/// as untrusted: no separators, no `..`, no surprises.
282fn sanitize_id(id: &str) -> String {
283 id.chars()
284 .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
285 .take(128)
286 .collect()
287}
288
289/// The settings block a user pastes into `~/.claude/settings.json`.
290pub fn hook_settings(exe: &str) -> String {
291 let events = [
292 ("SessionStart", ""),
293 ("UserPromptSubmit", ""),
294 ("PreToolUse", "*"),
295 ("PostToolUse", "*"),
296 ("Notification", ""),
297 ("Stop", ""),
298 ("SessionEnd", ""),
299 ];
300 let blocks: Vec<String> = events
301 .iter()
302 .map(|(event, matcher)| {
303 let matcher = if matcher.is_empty() {
304 String::new()
305 } else {
306 format!("\"matcher\": \"{matcher}\", ")
307 };
308 format!(
309 " \"{event}\": [\n {{ {matcher}\"hooks\": [{{ \"type\": \"command\", \"command\": \"{exe} hook\" }}] }}\n ]"
310 )
311 })
312 .collect();
313 format!("{{\n \"hooks\": {{\n{}\n }}\n}}", blocks.join(",\n"))
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319
320 /// Each test gets its own config dir; `apply` writes to a real path. The
321 /// lock is load-bearing: the dir is selected by an env var, which is
322 /// process-wide, and cargo runs these tests on threads.
323 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
324
325 fn with_temp_dir(f: impl FnOnce()) {
326 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
327 let tmp = tempfile::tempdir().unwrap();
328 // SAFETY: single-threaded test, and the var is read through
329 // paths::config_dir() only.
330 unsafe { std::env::set_var("TERMBRIDGE_CONFIG_DIR", tmp.path()) };
331 f();
332 unsafe { std::env::remove_var("TERMBRIDGE_CONFIG_DIR") };
333 }
334
335 fn event(json: serde_json::Value) -> Option<Record> {
336 apply(&json).unwrap()
337 }
338
339 #[test]
340 fn tracks_the_documented_lifecycle() {
341 with_temp_dir(|| {
342 let r = event(serde_json::json!({
343 "session_id": "abc", "hook_event_name": "UserPromptSubmit",
344 "cwd": "/tmp/p", "permission_mode": "acceptEdits",
345 }))
346 .unwrap();
347 assert_eq!(r.state, State::Working);
348 assert_eq!(r.mode.as_deref(), Some("acceptEdits"));
349
350 let r = event(serde_json::json!({
351 "session_id": "abc", "hook_event_name": "PreToolUse",
352 "cwd": "/tmp/p", "permission_mode": "acceptEdits", "tool_name": "Bash",
353 }))
354 .unwrap();
355 assert_eq!(r.tool.as_deref(), Some("Bash"));
356
357 let r = event(serde_json::json!({
358 "session_id": "abc", "hook_event_name": "Stop", "cwd": "/tmp/p",
359 }))
360 .unwrap();
361 assert_eq!(r.state, State::Idle);
362 assert_eq!(r.tool, None);
363 });
364 }
365
366 #[test]
367 fn notification_distinguishes_asking_from_idling() {
368 with_temp_dir(|| {
369 let asking = event(serde_json::json!({
370 "session_id": "a", "hook_event_name": "Notification",
371 "notification_type": "permission_prompt", "message": "Bash needs approval",
372 }))
373 .unwrap();
374 assert_eq!(asking.state, State::Waiting);
375 assert_eq!(asking.message.as_deref(), Some("Bash needs approval"));
376
377 let idling = event(serde_json::json!({
378 "session_id": "b", "hook_event_name": "Notification",
379 "notification_type": "idle_prompt",
380 }))
381 .unwrap();
382 assert_eq!(idling.state, State::Idle);
383 });
384 }
385
386 /// Notification carries no `permission_mode`, and that is exactly when the
387 /// user wants to see it.
388 #[test]
389 fn mode_survives_events_that_omit_it() {
390 with_temp_dir(|| {
391 event(serde_json::json!({
392 "session_id": "a", "hook_event_name": "UserPromptSubmit",
393 "permission_mode": "plan", "cwd": "/tmp/p",
394 }));
395 let r = event(serde_json::json!({
396 "session_id": "a", "hook_event_name": "Notification",
397 "notification_type": "permission_prompt",
398 }))
399 .unwrap();
400 assert_eq!(r.mode.as_deref(), Some("plan"));
401 assert_eq!(r.cwd, "/tmp/p");
402 });
403 }
404
405 /// `model` only ever arrives on `SessionStart`; every later event has to
406 /// keep reporting it anyway.
407 #[test]
408 fn model_survives_events_that_omit_it() {
409 with_temp_dir(|| {
410 event(serde_json::json!({
411 "session_id": "a", "hook_event_name": "SessionStart",
412 "cwd": "/tmp/p", "model": "claude-opus-4-1-20250805",
413 }));
414 let r = event(serde_json::json!({
415 "session_id": "a", "hook_event_name": "PreToolUse", "tool_name": "Bash",
416 }))
417 .unwrap();
418 assert_eq!(r.model.as_deref(), Some("claude-opus-4-1-20250805"));
419 });
420 }
421
422 #[test]
423 fn session_end_removes_the_record() {
424 with_temp_dir(|| {
425 event(serde_json::json!({
426 "session_id": "a", "hook_event_name": "SessionStart", "cwd": "/tmp/p",
427 }));
428 assert_eq!(read_all().len(), 1);
429 assert!(
430 event(serde_json::json!({
431 "session_id": "a", "hook_event_name": "SessionEnd", "reason": "clear",
432 }))
433 .is_none()
434 );
435 assert!(read_all().is_empty());
436 });
437 }
438
439 #[test]
440 fn unknown_events_and_ids_are_ignored() {
441 with_temp_dir(|| {
442 assert!(event(serde_json::json!({"hook_event_name": "Stop"})).is_none());
443 assert!(
444 event(serde_json::json!({"session_id": "a", "hook_event_name": "Wat"})).is_none()
445 );
446 assert!(read_all().is_empty());
447 });
448 }
449
450 /// The title has to survive the whole turn: the prompt arrives once and
451 /// every event after it is a tool call that knows nothing about it.
452 #[test]
453 fn the_prompt_outlives_the_event_that_carried_it() {
454 with_temp_dir(|| {
455 let r = event(serde_json::json!({
456 "session_id": "a", "hook_event_name": "UserPromptSubmit",
457 "cwd": "/tmp/p", "prompt": "fix the flaky pty test",
458 }))
459 .unwrap();
460 assert_eq!(r.prompt.as_deref(), Some("fix the flaky pty test"));
461
462 let r = event(serde_json::json!({
463 "session_id": "a", "hook_event_name": "PreToolUse", "tool_name": "Bash",
464 }))
465 .unwrap();
466 assert_eq!(r.prompt.as_deref(), Some("fix the flaky pty test"));
467 });
468 }
469
470 #[test]
471 fn a_prompt_becomes_one_line_of_title() {
472 assert_eq!(
473 summarize(" fix the\n\tflaky test ").as_deref(),
474 Some("fix the flaky test")
475 );
476 // Escape sequences would otherwise reach a terminal's title.
477 assert_eq!(
478 summarize("red \x1b[31mtext").as_deref(),
479 Some("red [31mtext")
480 );
481 assert_eq!(summarize(" \n ").as_deref(), None);
482 }
483
484 #[test]
485 fn a_long_prompt_is_cut_at_a_word() {
486 let long = "please refactor the websocket handshake so that pairing happens \
487 before the upgrade rather than after it";
488 let cut = summarize(long).unwrap();
489 assert!(cut.ends_with('…'), "{cut}");
490 assert!(cut.chars().count() <= PROMPT_MAX + 1, "{cut}");
491 assert!(!cut.contains(" "));
492 // Cut at a boundary, so no half word before the ellipsis.
493 let body = cut.trim_end_matches('…');
494 assert!(long.starts_with(body), "{cut}");
495 assert!(long[body.len()..].starts_with(' '), "{cut}");
496
497 // A single unbroken run has no boundary to prefer; it is cut anyway
498 // rather than allowed through at full length.
499 let wall = "x".repeat(200);
500 assert_eq!(summarize(&wall).unwrap().chars().count(), PROMPT_MAX + 1);
501 }
502
503 #[test]
504 fn session_ids_cannot_escape_the_directory() {
505 assert_eq!(sanitize_id("../../etc/passwd"), "etcpasswd");
506 assert_eq!(sanitize_id("abc-123_XY"), "abc-123_XY");
507 }
508}