1use std::path::{Path, PathBuf};
2use std::process::Command;
3use std::time::Duration;
4
5use tam_proto::AgentState;
6
7pub struct ContextUsage {
9 pub used_tokens: u64,
10 pub limit_tokens: u64,
11}
12
13impl ContextUsage {
14 pub fn percent(&self) -> u8 {
15 if self.limit_tokens == 0 {
16 return 0;
17 }
18 let pct = (self.used_tokens as f64 / self.limit_tokens as f64 * 100.0).round() as u64;
19 pct.min(100) as u8
20 }
21}
22
23pub trait Provider: Send + Sync {
29 fn name(&self) -> &str;
31
32 fn build_command(
34 &self,
35 dir: &Path,
36 args: &[String],
37 resume_session: Option<&str>,
38 prompt: Option<&str>,
39 ) -> Command;
40
41 fn detect_state_from_output(
44 &self,
45 recent_output: &[u8],
46 idle_duration: Duration,
47 ) -> Option<AgentState>;
48
49 fn map_hook_event(&self, event: &str) -> Option<AgentState>;
52
53 fn context_usage(&self, _pid: u32, _dir: &Path) -> Option<ContextUsage> {
56 None
57 }
58}
59
60pub struct ClaudeProvider;
65
66impl Provider for ClaudeProvider {
67 fn name(&self) -> &str {
68 "claude"
69 }
70
71 fn build_command(
72 &self,
73 dir: &Path,
74 args: &[String],
75 resume_session: Option<&str>,
76 prompt: Option<&str>,
77 ) -> Command {
78 let mut cmd = Command::new("claude");
79 cmd.current_dir(dir);
80 if let Some(id) = resume_session {
81 cmd.arg("--resume").arg(id);
82 }
83 cmd.args(args);
84 if let Some(text) = prompt {
85 cmd.arg(text);
86 }
87 cmd
88 }
89
90 fn detect_state_from_output(
91 &self,
92 _recent_output: &[u8],
93 _idle_duration: Duration,
94 ) -> Option<AgentState> {
95 None
97 }
98
99 fn map_hook_event(&self, event: &str) -> Option<AgentState> {
100 match event {
101 "user_prompt_submit" => Some(AgentState::Working),
103 "stop" | "notification:idle_prompt" => Some(AgentState::Input),
105 "notification:permission_prompt" => Some(AgentState::Blocked),
107 _ => None,
108 }
109 }
110
111 fn context_usage(&self, pid: u32, dir: &Path) -> Option<ContextUsage> {
112 claude_context_usage(pid, dir)
113 }
114}
115
116use serde::Deserialize;
119
120#[derive(Deserialize)]
121struct ClaudeSessionFile {
122 #[serde(rename = "sessionId")]
123 session_id: String,
124}
125
126#[derive(Deserialize)]
127struct ClaudeJournalLine {
128 #[serde(rename = "type")]
129 line_type: Option<String>,
130 message: Option<ClaudeJournalMessage>,
131}
132
133#[derive(Deserialize)]
134struct ClaudeJournalMessage {
135 model: Option<String>,
136 usage: Option<ClaudeUsage>,
137}
138
139#[derive(Deserialize)]
140struct ClaudeUsage {
141 #[serde(default)]
142 input_tokens: u64,
143 #[serde(default)]
144 cache_creation_input_tokens: u64,
145 #[serde(default)]
146 cache_read_input_tokens: u64,
147}
148
149fn claude_context_usage(pid: u32, dir: &Path) -> Option<ContextUsage> {
150 let home = std::env::var("HOME").ok()?;
151 let claude_dir = PathBuf::from(&home).join(".claude");
152
153 let encoded_dir = encode_claude_path(dir);
155 let project_dir = claude_dir.join("projects").join(&encoded_dir);
156
157 let jsonl_path = claude_session_jsonl_from_pid(pid, &claude_dir, &project_dir)
162 .or_else(|| claude_most_recent_jsonl(&project_dir))?;
163
164 let content = std::fs::read_to_string(&jsonl_path).ok()?;
166 let (usage, model) = find_last_usage(&content)?;
167
168 let used =
169 usage.input_tokens + usage.cache_creation_input_tokens + usage.cache_read_input_tokens;
170
171 let limit = if model.as_deref().is_some_and(|m| m.contains("[1m]")) || used > 180_000 {
173 1_000_000
174 } else {
175 200_000
176 };
177
178 Some(ContextUsage {
179 used_tokens: used,
180 limit_tokens: limit,
181 })
182}
183
184fn claude_session_jsonl_from_pid(
186 pid: u32,
187 claude_dir: &Path,
188 project_dir: &Path,
189) -> Option<PathBuf> {
190 let session_path = claude_dir.join("sessions").join(format!("{pid}.json"));
191 let session_content = std::fs::read_to_string(&session_path).ok()?;
192 let session: ClaudeSessionFile = serde_json::from_str(&session_content).ok()?;
193 let path = project_dir.join(format!("{}.jsonl", session.session_id));
194 if path.exists() {
195 Some(path)
196 } else {
197 None
198 }
199}
200
201fn claude_most_recent_jsonl(project_dir: &Path) -> Option<PathBuf> {
203 let entries = std::fs::read_dir(project_dir).ok()?;
204 entries
205 .filter_map(|e| e.ok())
206 .filter(|e| e.path().extension().is_some_and(|ext| ext == "jsonl"))
207 .max_by_key(|e| {
208 e.metadata()
209 .and_then(|m| m.modified())
210 .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
211 })
212 .map(|e| e.path())
213}
214
215fn encode_claude_path(dir: &Path) -> String {
216 let s = dir.to_string_lossy();
217 s.replace('/', "-")
218}
219
220fn find_last_usage(content: &str) -> Option<(ClaudeUsage, Option<String>)> {
221 for line in content.lines().rev() {
222 let entry: ClaudeJournalLine = match serde_json::from_str(line) {
223 Ok(e) => e,
224 Err(_) => continue,
225 };
226 if entry.line_type.as_deref() != Some("assistant") {
227 continue;
228 }
229 if let Some(msg) = entry.message {
230 if let Some(usage) = msg.usage {
231 return Some((usage, msg.model));
232 }
233 }
234 }
235 None
236}
237
238pub struct CodexProvider;
243
244impl Provider for CodexProvider {
245 fn name(&self) -> &str {
246 "codex"
247 }
248
249 fn build_command(
250 &self,
251 dir: &Path,
252 args: &[String],
253 resume_session: Option<&str>,
254 prompt: Option<&str>,
255 ) -> Command {
256 let mut cmd = Command::new("codex");
257 if let Some(id) = resume_session {
258 cmd.arg("resume").arg(id);
259 }
260 cmd.arg("-C").arg(dir);
261 cmd.args(args);
262 if let Some(text) = prompt {
263 cmd.arg(text);
264 }
265 cmd
266 }
267
268 fn detect_state_from_output(
269 &self,
270 _recent_output: &[u8],
271 idle_duration: Duration,
272 ) -> Option<AgentState> {
273 if idle_duration >= Duration::from_secs(5) {
274 Some(AgentState::Idle)
275 } else {
276 Some(AgentState::Working)
277 }
278 }
279
280 fn map_hook_event(&self, _event: &str) -> Option<AgentState> {
281 None
282 }
283
284 fn context_usage(&self, _pid: u32, dir: &Path) -> Option<ContextUsage> {
285 codex_context_usage(dir)
286 }
287}
288
289#[derive(Deserialize)]
292struct CodexJournalLine {
293 #[serde(rename = "type")]
294 line_type: Option<String>,
295 payload: Option<CodexPayload>,
296}
297
298#[derive(Deserialize)]
299struct CodexPayload {
300 #[serde(rename = "type")]
301 payload_type: Option<String>,
302 info: Option<CodexTokenInfo>,
303 cwd: Option<String>,
305}
306
307#[derive(Deserialize)]
308struct CodexTokenInfo {
309 last_token_usage: Option<CodexTokenUsage>,
310 model_context_window: Option<u64>,
311}
312
313#[derive(Deserialize)]
314struct CodexTokenUsage {
315 #[serde(default)]
316 input_tokens: u64,
317}
318
319fn find_codex_session(codex_dir: &Path, agent_dir: &Path) -> Option<PathBuf> {
321 let sessions_dir = codex_dir.join("sessions");
322 if !sessions_dir.is_dir() {
323 return None;
324 }
325
326 let mut year_dirs: Vec<_> = std::fs::read_dir(&sessions_dir)
328 .ok()?
329 .filter_map(|e| e.ok())
330 .filter(|e| e.path().is_dir())
331 .collect();
332 year_dirs.sort_by_key(|e| std::cmp::Reverse(e.file_name()));
333
334 for year in year_dirs {
335 let mut month_dirs: Vec<_> = std::fs::read_dir(year.path())
336 .ok()?
337 .filter_map(|e| e.ok())
338 .filter(|e| e.path().is_dir())
339 .collect();
340 month_dirs.sort_by_key(|e| std::cmp::Reverse(e.file_name()));
341
342 for month in month_dirs {
343 let mut day_dirs: Vec<_> = std::fs::read_dir(month.path())
344 .ok()?
345 .filter_map(|e| e.ok())
346 .filter(|e| e.path().is_dir())
347 .collect();
348 day_dirs.sort_by_key(|e| std::cmp::Reverse(e.file_name()));
349
350 for day in day_dirs {
351 let mut files: Vec<_> = std::fs::read_dir(day.path())
353 .ok()?
354 .filter_map(|e| e.ok())
355 .filter(|e| e.path().extension().is_some_and(|ext| ext == "jsonl"))
356 .collect();
357 files.sort_by_key(|e| std::cmp::Reverse(e.file_name()));
358
359 for file in files {
360 if let Ok(content) = std::fs::read_to_string(file.path()) {
362 if let Some(first_line) = content.lines().next() {
363 if let Ok(meta) = serde_json::from_str::<CodexJournalLine>(first_line) {
364 let cwd = meta.payload.as_ref().and_then(|p| p.cwd.as_deref());
365 if cwd == Some(&agent_dir.to_string_lossy()) {
366 return Some(file.path());
367 }
368 }
369 }
370 }
371 }
372 }
373 }
374 }
375
376 None
377}
378
379fn codex_context_usage(dir: &Path) -> Option<ContextUsage> {
380 let home = std::env::var("HOME").ok()?;
381 let codex_dir = PathBuf::from(&home).join(".codex");
382
383 let session_path = find_codex_session(&codex_dir, dir)?;
384 let content = std::fs::read_to_string(&session_path).ok()?;
385
386 for line in content.lines().rev() {
388 let entry: CodexJournalLine = match serde_json::from_str(line) {
389 Ok(e) => e,
390 Err(_) => continue,
391 };
392 if entry.line_type.as_deref() != Some("event_msg") {
393 continue;
394 }
395 let payload = entry.payload?;
396 if payload.payload_type.as_deref() != Some("token_count") {
397 continue;
398 }
399 let info = payload.info?;
400 let usage = info.last_token_usage?;
401 let limit = info.model_context_window?;
402
403 return Some(ContextUsage {
404 used_tokens: usage.input_tokens,
405 limit_tokens: limit,
406 });
407 }
408
409 None
410}
411
412pub struct GenericProvider {
416 command: String,
417 idle_timeout: Duration,
418}
419
420impl GenericProvider {
421 pub fn new(command: &str) -> Self {
422 Self {
423 command: command.to_string(),
424 idle_timeout: Duration::from_secs(5),
425 }
426 }
427}
428
429impl Provider for GenericProvider {
430 fn name(&self) -> &str {
431 &self.command
432 }
433
434 fn build_command(
435 &self,
436 dir: &Path,
437 args: &[String],
438 _resume_session: Option<&str>,
439 _prompt: Option<&str>,
440 ) -> Command {
441 let mut cmd = Command::new(&self.command);
442 cmd.current_dir(dir);
443 cmd.args(args);
444 cmd
445 }
446
447 fn detect_state_from_output(
448 &self,
449 _recent_output: &[u8],
450 idle_duration: Duration,
451 ) -> Option<AgentState> {
452 if idle_duration >= self.idle_timeout {
453 Some(AgentState::Idle)
454 } else {
455 Some(AgentState::Working)
456 }
457 }
458
459 fn map_hook_event(&self, _event: &str) -> Option<AgentState> {
460 None
462 }
463}
464
465pub fn resolve(name: &str) -> Box<dyn Provider> {
467 match name {
468 "claude" => Box::new(ClaudeProvider),
469 "codex" => Box::new(CodexProvider),
470 other => Box::new(GenericProvider::new(other)),
471 }
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477 use std::path::PathBuf;
478
479 #[test]
480 fn claude_provider_basics() {
481 let p = ClaudeProvider;
482 assert_eq!(p.name(), "claude");
483
484 let cmd = p.build_command(&PathBuf::from("/tmp"), &["--verbose".into()], None, None);
485 assert_eq!(cmd.get_program(), "claude");
486 assert_eq!(cmd.get_current_dir(), Some(Path::new("/tmp")));
487 let args: Vec<_> = cmd.get_args().collect();
488 assert_eq!(args, &["--verbose"]);
489 }
490
491 #[test]
492 fn claude_resume_session() {
493 let p = ClaudeProvider;
494 let cmd = p.build_command(&PathBuf::from("/tmp"), &[], Some("abc-123"), None);
495 let args: Vec<_> = cmd.get_args().collect();
496 assert_eq!(args, &["--resume", "abc-123"]);
497 }
498
499 #[test]
500 fn claude_prompt_arg() {
501 let p = ClaudeProvider;
502 let cmd = p.build_command(&PathBuf::from("/tmp"), &[], None, Some("fix the bug"));
503 let args: Vec<_> = cmd.get_args().collect();
504 assert_eq!(args, &["fix the bug"]);
505 }
506
507 #[test]
508 fn claude_resume_session_and_prompt() {
509 let p = ClaudeProvider;
510 let cmd = p.build_command(
511 &PathBuf::from("/tmp"),
512 &[],
513 Some("abc-123"),
514 Some("fix the bug"),
515 );
516 let args: Vec<_> = cmd.get_args().collect();
517 assert_eq!(args, &["--resume", "abc-123", "fix the bug"]);
518 }
519
520 #[test]
521 fn claude_returns_none_for_output_detection() {
522 let p = ClaudeProvider;
523 assert_eq!(
524 p.detect_state_from_output(b"anything", Duration::from_secs(0)),
525 None
526 );
527 }
528
529 #[test]
530 fn generic_provider_basics() {
531 let p = GenericProvider::new("codex");
532 assert_eq!(p.name(), "codex");
533
534 let cmd = p.build_command(&PathBuf::from("/home"), &[], None, None);
535 assert_eq!(cmd.get_program(), "codex");
536 }
537
538 #[test]
539 fn generic_working_when_active() {
540 let p = GenericProvider::new("test");
541 let state = p.detect_state_from_output(b"output", Duration::from_secs(1));
542 assert_eq!(state, Some(AgentState::Working));
543 }
544
545 #[test]
546 fn generic_idle_after_timeout() {
547 let p = GenericProvider::new("test");
548 let state = p.detect_state_from_output(b"", Duration::from_secs(6));
549 assert_eq!(state, Some(AgentState::Idle));
550 }
551
552 #[test]
553 fn resolve_claude() {
554 let p = resolve("claude");
555 assert_eq!(p.name(), "claude");
556 }
557
558 #[test]
559 fn resolve_unknown_gives_generic() {
560 let p = resolve("my-agent");
561 assert_eq!(p.name(), "my-agent");
562 }
563
564 #[test]
565 fn claude_hook_stop_maps_to_input() {
566 let p = ClaudeProvider;
567 assert_eq!(p.map_hook_event("stop"), Some(AgentState::Input));
568 assert_eq!(
569 p.map_hook_event("notification:idle_prompt"),
570 Some(AgentState::Input)
571 );
572 }
573
574 #[test]
575 fn claude_hook_permission_maps_to_blocked() {
576 let p = ClaudeProvider;
577 assert_eq!(
578 p.map_hook_event("notification:permission_prompt"),
579 Some(AgentState::Blocked)
580 );
581 }
582
583 #[test]
584 fn claude_hook_user_prompt_maps_to_working() {
585 let p = ClaudeProvider;
586 assert_eq!(
587 p.map_hook_event("user_prompt_submit"),
588 Some(AgentState::Working)
589 );
590 }
591
592 #[test]
593 fn claude_hook_unknown_returns_none() {
594 let p = ClaudeProvider;
595 assert_eq!(p.map_hook_event("something_else"), None);
596 }
597
598 #[test]
599 fn generic_hook_always_none() {
600 let p = GenericProvider::new("test");
601 assert_eq!(p.map_hook_event("stop"), None);
602 }
603
604 #[test]
605 fn generic_context_usage_returns_none() {
606 let p = GenericProvider::new("test");
607 assert!(p.context_usage(1234, Path::new("/tmp")).is_none());
608 }
609
610 #[test]
611 fn context_usage_percent() {
612 let cu = ContextUsage {
613 used_tokens: 150_000,
614 limit_tokens: 200_000,
615 };
616 assert_eq!(cu.percent(), 75);
617 }
618
619 #[test]
620 fn context_usage_percent_zero_limit() {
621 let cu = ContextUsage {
622 used_tokens: 100,
623 limit_tokens: 0,
624 };
625 assert_eq!(cu.percent(), 0);
626 }
627
628 #[test]
629 fn context_usage_percent_clamped() {
630 let cu = ContextUsage {
631 used_tokens: 250_000,
632 limit_tokens: 200_000,
633 };
634 assert_eq!(cu.percent(), 100);
635 }
636
637 #[test]
638 fn encode_claude_path_basic() {
639 assert_eq!(
640 encode_claude_path(Path::new("/home/user/Workspace/tam")),
641 "-home-user-Workspace-tam"
642 );
643 }
644
645 #[test]
646 fn encode_claude_path_root() {
647 assert_eq!(encode_claude_path(Path::new("/")), "-");
648 }
649
650 #[test]
651 fn find_last_usage_basic() {
652 let jsonl = r#"{"type":"user","message":{"content":"hello"}}
653{"type":"assistant","message":{"model":"claude-opus-4-6[1m]","usage":{"input_tokens":100,"cache_creation_input_tokens":200,"cache_read_input_tokens":300}}}
654{"type":"user","message":{"content":"bye"}}"#;
655 let (usage, model) = find_last_usage(jsonl).unwrap();
656 assert_eq!(usage.input_tokens, 100);
657 assert_eq!(usage.cache_creation_input_tokens, 200);
658 assert_eq!(usage.cache_read_input_tokens, 300);
659 assert_eq!(model.as_deref(), Some("claude-opus-4-6[1m]"));
660 }
661
662 #[test]
663 fn find_last_usage_returns_last() {
664 let jsonl = r#"{"type":"assistant","message":{"model":"m1","usage":{"input_tokens":10,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}
665{"type":"assistant","message":{"model":"m2","usage":{"input_tokens":50,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}"#;
666 let (usage, model) = find_last_usage(jsonl).unwrap();
667 assert_eq!(usage.input_tokens, 50);
668 assert_eq!(model.as_deref(), Some("m2"));
669 }
670
671 #[test]
672 fn find_last_usage_none_when_empty() {
673 assert!(find_last_usage("").is_none());
674 }
675
676 #[test]
677 fn find_last_usage_skips_malformed() {
678 let jsonl = "not json\n{\"type\":\"assistant\",\"message\":{\"usage\":{\"input_tokens\":42,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0}}}";
679 let (usage, _) = find_last_usage(jsonl).unwrap();
680 assert_eq!(usage.input_tokens, 42);
681 }
682
683 #[test]
686 fn codex_provider_basics() {
687 let p = CodexProvider;
688 assert_eq!(p.name(), "codex");
689
690 let cmd = p.build_command(&PathBuf::from("/tmp/project"), &[], None, None);
691 assert_eq!(cmd.get_program(), "codex");
692 let args: Vec<_> = cmd.get_args().collect();
693 assert_eq!(args, &["-C", "/tmp/project"]);
694 }
695
696 #[test]
697 fn codex_resume_session() {
698 let p = CodexProvider;
699 let cmd = p.build_command(&PathBuf::from("/tmp"), &[], Some("sess-456"), None);
700 let args: Vec<_> = cmd.get_args().collect();
701 assert_eq!(args, &["resume", "sess-456", "-C", "/tmp"]);
702 }
703
704 #[test]
705 fn codex_prompt_arg() {
706 let p = CodexProvider;
707 let cmd = p.build_command(&PathBuf::from("/tmp"), &[], None, Some("fix the bug"));
708 let args: Vec<_> = cmd.get_args().collect();
709 assert_eq!(args, &["-C", "/tmp", "fix the bug"]);
710 }
711
712 #[test]
713 fn codex_resume_session_and_prompt() {
714 let p = CodexProvider;
715 let cmd = p.build_command(
716 &PathBuf::from("/tmp"),
717 &[],
718 Some("sess-456"),
719 Some("fix the bug"),
720 );
721 let args: Vec<_> = cmd.get_args().collect();
722 assert_eq!(args, &["resume", "sess-456", "-C", "/tmp", "fix the bug"]);
723 }
724
725 #[test]
726 fn codex_pty_heuristic_working() {
727 let p = CodexProvider;
728 assert_eq!(
729 p.detect_state_from_output(b"output", Duration::from_secs(1)),
730 Some(AgentState::Working)
731 );
732 }
733
734 #[test]
735 fn codex_pty_heuristic_idle() {
736 let p = CodexProvider;
737 assert_eq!(
738 p.detect_state_from_output(b"", Duration::from_secs(6)),
739 Some(AgentState::Idle)
740 );
741 }
742
743 #[test]
744 fn codex_hook_always_none() {
745 let p = CodexProvider;
746 assert_eq!(p.map_hook_event("stop"), None);
747 }
748
749 #[test]
750 fn resolve_codex() {
751 let p = resolve("codex");
752 assert_eq!(p.name(), "codex");
753 }
754
755 #[test]
756 fn codex_find_last_token_count() {
757 let jsonl = r#"{"type":"session_meta","payload":{"cwd":"/tmp/project"}}
759{"type":"response_item","payload":{"type":"message","role":"user"}}
760{"type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":50000},"model_context_window":258400}}}"#;
761
762 for line in jsonl.lines().rev() {
764 let entry: CodexJournalLine = match serde_json::from_str(line) {
765 Ok(e) => e,
766 Err(_) => continue,
767 };
768 if entry.line_type.as_deref() != Some("event_msg") {
769 continue;
770 }
771 let payload = entry.payload.unwrap();
772 if payload.payload_type.as_deref() != Some("token_count") {
773 continue;
774 }
775 let info = payload.info.unwrap();
776 let usage = info.last_token_usage.unwrap();
777 assert_eq!(usage.input_tokens, 50000);
778 assert_eq!(info.model_context_window.unwrap(), 258400);
779 return;
780 }
781 panic!("token_count event not found");
782 }
783}