anvilsign in

collin/browser-terminal-extension

main / daemon / src / agents.rs
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`. Read from the transcript
67 /// where there is one — see [`model_from_transcript`] — because the hook
68 /// event only carries a model on `SessionStart`, and even there not always.
69 /// Carried forward across events that turn up neither, the way `mode` and
70 /// `name` are.
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 // The transcript first: it is the only source that keeps up with
199 // `/model`. The event's own field is the answer for a session whose
200 // first turn hasn't been written yet, which is exactly `SessionStart`.
201 model: str_field("transcript_path")
202 .as_deref()
203 .and_then(model_from_transcript)
204 .or_else(|| str_field("model"))
205 .or_else(|| carry(|r| r.model.clone())),
206 // Only `UserPromptSubmit` carries one; every other event keeps the one
207 // already on disk, so the title stays put for the whole turn instead of
208 // blinking out on the first tool call.
209 prompt: str_field("prompt")
210 .as_deref()
211 .and_then(summarize)
212 .or_else(|| carry(|r| r.prompt.clone())),
213 // Wrapping, and the glyph indexes into a six-frame cycle: the absolute
214 // count is never read, only its position in the loop.
215 frame: previous.as_ref().map_or(0, |p| p.frame.wrapping_add(1)),
216 updated: now(),
217 };
218
219 write(&path, &record)?;
220 Ok(Some(record))
221}
222
223/// How much of the tail of a transcript is read looking for a model. A turn is
224/// a few kilobytes at most, so this covers the last several of them; the point
225/// is only that a megabyte-long transcript is not read on every hook event.
226const TRANSCRIPT_TAIL: u64 = 64 * 1024;
227
228/// The model a session is actually using, from the end of its transcript.
229///
230/// Only `SessionStart` carries a `model` field, so a session that switches with
231/// `/model` mid-conversation would otherwise keep reporting the one it started
232/// with. Every assistant turn in the transcript names the model that wrote it,
233/// so the last one is the live answer.
234///
235/// Sidechain turns are skipped: those are subagents, which run whatever model
236/// they were spawned with, and the pane belongs to the session.
237///
238/// `None` for anything unreadable or not yet written — a transcript with no
239/// assistant turn in its tail is the normal state of a session that has only
240/// just started.
241fn model_from_transcript(path: &str) -> Option<String> {
242 use std::io::{Read, Seek, SeekFrom};
243
244 let mut file = fs::File::open(path).ok()?;
245 let len = file.metadata().ok()?.len();
246 let start = len.saturating_sub(TRANSCRIPT_TAIL);
247 file.seek(SeekFrom::Start(start)).ok()?;
248 let mut buf = Vec::new();
249 file.read_to_end(&mut buf).ok()?;
250 // The seek can land inside a character as easily as inside a line; both are
251 // the same problem, and dropping the first partial line solves both.
252 let text = String::from_utf8_lossy(&buf);
253 let mut lines: Vec<&str> = text.lines().collect();
254 if start > 0 && !lines.is_empty() {
255 lines.remove(0);
256 }
257 for line in lines.iter().rev() {
258 let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
259 continue;
260 };
261 if v.get("type").and_then(|t| t.as_str()) != Some("assistant") {
262 continue;
263 }
264 if v.get("isSidechain").and_then(|s| s.as_bool()) == Some(true) {
265 continue;
266 }
267 if let Some(model) = v
268 .get("message")
269 .and_then(|m| m.get("model"))
270 .and_then(|m| m.as_str())
271 {
272 return Some(model.to_string());
273 }
274 }
275 None
276}
277
278fn write(path: &std::path::Path, record: &Record) -> io::Result<()> {
279 let dir = path.parent().expect("record path has a parent");
280 fs::create_dir_all(dir)?;
281 let json = serde_json::to_string(record).map_err(io::Error::other)?;
282 // Write-then-rename: the daemon polls this directory and must never read a
283 // half-written file.
284 let tmp = path.with_extension("tmp");
285 fs::write(&tmp, json)?;
286 fs::rename(&tmp, path)
287}
288
289/// How much of a prompt is kept. Long enough that a sentence usually survives
290/// whole, short enough that a record, a pane title and a status line entry are
291/// all bounded. Anything that has to be shorter still — a narrow status line,
292/// a sidebar tab — trims what it renders rather than what is stored.
293const PROMPT_MAX: usize = 80;
294
295/// A prompt as a single line of title.
296///
297/// Prompts are pasted stack traces and multi-paragraph essays as often as they
298/// are one sentence, and they reach a pane title, so they are flattened to one
299/// line, stripped of control characters, and cut. `None` for a prompt with
300/// nothing printable in it — better to keep the previous title than to blank it.
301pub fn summarize(prompt: &str) -> Option<String> {
302 let mut out = String::new();
303 let mut space = false;
304 for c in prompt.chars() {
305 if c.is_whitespace() {
306 space = !out.is_empty();
307 continue;
308 }
309 // Control characters, and the escape sequences built from them, would
310 // be written straight into a terminal's title.
311 if c.is_control() {
312 continue;
313 }
314 if space {
315 out.push(' ');
316 space = false;
317 }
318 out.push(c);
319 // One past the limit, so the caller below can tell "exactly full" from
320 // "there was more".
321 if out.chars().count() > PROMPT_MAX {
322 break;
323 }
324 }
325 if out.is_empty() {
326 return None;
327 }
328 if out.chars().count() <= PROMPT_MAX {
329 return Some(out);
330 }
331 let mut cut: String = out.chars().take(PROMPT_MAX).collect();
332 // Prefer a word boundary, but only a late one: cutting "refactor the
333 // authentication" back to "refactor" loses more than the ragged edge costs.
334 let floor = cut.len() * 2 / 3;
335 if let Some(i) = cut.rfind(' ').filter(|i| *i >= floor) {
336 cut.truncate(i);
337 }
338 cut.push('…');
339 Some(cut)
340}
341
342/// Session ids come from Claude, but they end up in a filename, so treat them
343/// as untrusted: no separators, no `..`, no surprises.
344fn sanitize_id(id: &str) -> String {
345 id.chars()
346 .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
347 .take(128)
348 .collect()
349}
350
351/// The settings block a user pastes into `~/.claude/settings.json`.
352pub fn hook_settings(exe: &str) -> String {
353 let events = [
354 ("SessionStart", ""),
355 ("UserPromptSubmit", ""),
356 ("PreToolUse", "*"),
357 ("PostToolUse", "*"),
358 ("Notification", ""),
359 ("Stop", ""),
360 ("SessionEnd", ""),
361 ];
362 let blocks: Vec<String> = events
363 .iter()
364 .map(|(event, matcher)| {
365 let matcher = if matcher.is_empty() {
366 String::new()
367 } else {
368 format!("\"matcher\": \"{matcher}\", ")
369 };
370 format!(
371 " \"{event}\": [\n {{ {matcher}\"hooks\": [{{ \"type\": \"command\", \"command\": \"{exe} hook\" }}] }}\n ]"
372 )
373 })
374 .collect();
375 format!("{{\n \"hooks\": {{\n{}\n }}\n}}", blocks.join(",\n"))
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381
382 /// Each test gets its own config dir; `apply` writes to a real path. The
383 /// lock is load-bearing: the dir is selected by an env var, which is
384 /// process-wide, and cargo runs these tests on threads.
385 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
386
387 fn with_temp_dir(f: impl FnOnce()) {
388 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
389 let tmp = tempfile::tempdir().unwrap();
390 // SAFETY: single-threaded test, and the var is read through
391 // paths::config_dir() only.
392 unsafe { std::env::set_var("TERMBRIDGE_CONFIG_DIR", tmp.path()) };
393 f();
394 unsafe { std::env::remove_var("TERMBRIDGE_CONFIG_DIR") };
395 }
396
397 fn event(json: serde_json::Value) -> Option<Record> {
398 apply(&json).unwrap()
399 }
400
401 #[test]
402 fn tracks_the_documented_lifecycle() {
403 with_temp_dir(|| {
404 let r = event(serde_json::json!({
405 "session_id": "abc", "hook_event_name": "UserPromptSubmit",
406 "cwd": "/tmp/p", "permission_mode": "acceptEdits",
407 }))
408 .unwrap();
409 assert_eq!(r.state, State::Working);
410 assert_eq!(r.mode.as_deref(), Some("acceptEdits"));
411
412 let r = event(serde_json::json!({
413 "session_id": "abc", "hook_event_name": "PreToolUse",
414 "cwd": "/tmp/p", "permission_mode": "acceptEdits", "tool_name": "Bash",
415 }))
416 .unwrap();
417 assert_eq!(r.tool.as_deref(), Some("Bash"));
418
419 let r = event(serde_json::json!({
420 "session_id": "abc", "hook_event_name": "Stop", "cwd": "/tmp/p",
421 }))
422 .unwrap();
423 assert_eq!(r.state, State::Idle);
424 assert_eq!(r.tool, None);
425 });
426 }
427
428 #[test]
429 fn notification_distinguishes_asking_from_idling() {
430 with_temp_dir(|| {
431 let asking = event(serde_json::json!({
432 "session_id": "a", "hook_event_name": "Notification",
433 "notification_type": "permission_prompt", "message": "Bash needs approval",
434 }))
435 .unwrap();
436 assert_eq!(asking.state, State::Waiting);
437 assert_eq!(asking.message.as_deref(), Some("Bash needs approval"));
438
439 let idling = event(serde_json::json!({
440 "session_id": "b", "hook_event_name": "Notification",
441 "notification_type": "idle_prompt",
442 }))
443 .unwrap();
444 assert_eq!(idling.state, State::Idle);
445 });
446 }
447
448 /// Notification carries no `permission_mode`, and that is exactly when the
449 /// user wants to see it.
450 #[test]
451 fn mode_survives_events_that_omit_it() {
452 with_temp_dir(|| {
453 event(serde_json::json!({
454 "session_id": "a", "hook_event_name": "UserPromptSubmit",
455 "permission_mode": "plan", "cwd": "/tmp/p",
456 }));
457 let r = event(serde_json::json!({
458 "session_id": "a", "hook_event_name": "Notification",
459 "notification_type": "permission_prompt",
460 }))
461 .unwrap();
462 assert_eq!(r.mode.as_deref(), Some("plan"));
463 assert_eq!(r.cwd, "/tmp/p");
464 });
465 }
466
467 /// `model` only ever arrives on `SessionStart`; every later event has to
468 /// keep reporting it anyway.
469 #[test]
470 fn model_survives_events_that_omit_it() {
471 with_temp_dir(|| {
472 event(serde_json::json!({
473 "session_id": "a", "hook_event_name": "SessionStart",
474 "cwd": "/tmp/p", "model": "claude-opus-4-1-20250805",
475 }));
476 let r = event(serde_json::json!({
477 "session_id": "a", "hook_event_name": "PreToolUse", "tool_name": "Bash",
478 }))
479 .unwrap();
480 assert_eq!(r.model.as_deref(), Some("claude-opus-4-1-20250805"));
481 });
482 }
483
484 /// A session that switched models with `/model` reports the new one, which
485 /// only the transcript knows about.
486 #[test]
487 fn transcript_beats_the_model_the_session_started_with() {
488 with_temp_dir(|| {
489 let transcript = dir().join("t.jsonl");
490 fs::create_dir_all(dir()).unwrap();
491 fs::write(
492 &transcript,
493 concat!(
494 r#"{"type":"user","message":{"role":"user"}}"#,
495 "\n",
496 r#"{"type":"assistant","message":{"model":"claude-sonnet-5"}}"#,
497 "\n",
498 r#"{"type":"assistant","message":{"model":"claude-opus-5"}}"#,
499 "\n",
500 // A subagent's turn, which is not what the pane is running.
501 r#"{"type":"assistant","isSidechain":true,"message":{"model":"claude-haiku-4-5"}}"#,
502 "\n",
503 ),
504 )
505 .unwrap();
506 event(serde_json::json!({
507 "session_id": "a", "hook_event_name": "SessionStart",
508 "cwd": "/tmp/p", "model": "claude-sonnet-5",
509 }));
510 let r = event(serde_json::json!({
511 "session_id": "a", "hook_event_name": "PreToolUse", "tool_name": "Bash",
512 "transcript_path": transcript.to_str().unwrap(),
513 }))
514 .unwrap();
515 assert_eq!(r.model.as_deref(), Some("claude-opus-5"));
516 });
517 }
518
519 /// A transcript with no assistant turn yet — every new session — leaves the
520 /// event's own field to answer.
521 #[test]
522 fn empty_transcript_falls_back_to_the_event() {
523 with_temp_dir(|| {
524 let transcript = dir().join("t.jsonl");
525 fs::create_dir_all(dir()).unwrap();
526 fs::write(&transcript, "").unwrap();
527 let r = event(serde_json::json!({
528 "session_id": "a", "hook_event_name": "SessionStart",
529 "cwd": "/tmp/p", "model": "claude-opus-5[1m]",
530 "transcript_path": transcript.to_str().unwrap(),
531 }))
532 .unwrap();
533 assert_eq!(r.model.as_deref(), Some("claude-opus-5[1m]"));
534 });
535 }
536
537 #[test]
538 fn session_end_removes_the_record() {
539 with_temp_dir(|| {
540 event(serde_json::json!({
541 "session_id": "a", "hook_event_name": "SessionStart", "cwd": "/tmp/p",
542 }));
543 assert_eq!(read_all().len(), 1);
544 assert!(
545 event(serde_json::json!({
546 "session_id": "a", "hook_event_name": "SessionEnd", "reason": "clear",
547 }))
548 .is_none()
549 );
550 assert!(read_all().is_empty());
551 });
552 }
553
554 #[test]
555 fn unknown_events_and_ids_are_ignored() {
556 with_temp_dir(|| {
557 assert!(event(serde_json::json!({"hook_event_name": "Stop"})).is_none());
558 assert!(
559 event(serde_json::json!({"session_id": "a", "hook_event_name": "Wat"})).is_none()
560 );
561 assert!(read_all().is_empty());
562 });
563 }
564
565 /// The title has to survive the whole turn: the prompt arrives once and
566 /// every event after it is a tool call that knows nothing about it.
567 #[test]
568 fn the_prompt_outlives_the_event_that_carried_it() {
569 with_temp_dir(|| {
570 let r = event(serde_json::json!({
571 "session_id": "a", "hook_event_name": "UserPromptSubmit",
572 "cwd": "/tmp/p", "prompt": "fix the flaky pty test",
573 }))
574 .unwrap();
575 assert_eq!(r.prompt.as_deref(), Some("fix the flaky pty test"));
576
577 let r = event(serde_json::json!({
578 "session_id": "a", "hook_event_name": "PreToolUse", "tool_name": "Bash",
579 }))
580 .unwrap();
581 assert_eq!(r.prompt.as_deref(), Some("fix the flaky pty test"));
582 });
583 }
584
585 #[test]
586 fn a_prompt_becomes_one_line_of_title() {
587 assert_eq!(
588 summarize(" fix the\n\tflaky test ").as_deref(),
589 Some("fix the flaky test")
590 );
591 // Escape sequences would otherwise reach a terminal's title.
592 assert_eq!(
593 summarize("red \x1b[31mtext").as_deref(),
594 Some("red [31mtext")
595 );
596 assert_eq!(summarize(" \n ").as_deref(), None);
597 }
598
599 #[test]
600 fn a_long_prompt_is_cut_at_a_word() {
601 let long = "please refactor the websocket handshake so that pairing happens \
602 before the upgrade rather than after it";
603 let cut = summarize(long).unwrap();
604 assert!(cut.ends_with('…'), "{cut}");
605 assert!(cut.chars().count() <= PROMPT_MAX + 1, "{cut}");
606 assert!(!cut.contains(" "));
607 // Cut at a boundary, so no half word before the ellipsis.
608 let body = cut.trim_end_matches('…');
609 assert!(long.starts_with(body), "{cut}");
610 assert!(long[body.len()..].starts_with(' '), "{cut}");
611
612 // A single unbroken run has no boundary to prefer; it is cut anyway
613 // rather than allowed through at full length.
614 let wall = "x".repeat(200);
615 assert_eq!(summarize(&wall).unwrap().chars().count(), PROMPT_MAX + 1);
616 }
617
618 #[test]
619 fn session_ids_cannot_escape_the_directory() {
620 assert_eq!(sanitize_id("../../etc/passwd"), "etcpasswd");
621 assert_eq!(sanitize_id("abc-123_XY"), "abc-123_XY");
622 }
623}