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