1use crate::pty_resume::Entry;
16use serde::Serialize;
17use serde_json::{json, Value};
18use std::io::{Read, Seek, SeekFrom};
19use std::path::{Path, PathBuf};
20use std::sync::OnceLock;
21
22const TAIL_BYTES: u64 = 2 * 1024 * 1024;
24const HEAD_BYTES: u64 = 256 * 1024;
25const PROMPTS: usize = 5;
26const TOOL_CALLS: usize = 20;
27const ERRORS: usize = 10;
28const MCP_TOOLS: usize = 100;
29const PROMPT_CHARS: usize = 500;
30const DETAIL_CHARS: usize = 200;
31const ERROR_CHARS: usize = 300;
32const APPROVAL_MS: u64 = 30_000;
35const HUNG_MS: u64 = 10 * 60_000;
36
37static STATE_DIR: OnceLock<PathBuf> = OnceLock::new();
38
39pub fn set_state_dir(dir: &Path) {
41 let _ = STATE_DIR.set(dir.to_path_buf());
42}
43
44#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
45#[serde(rename_all = "snake_case")]
46pub enum Stopped {
47 AwaitingApproval,
48 Crashed,
49 Hung,
50 Finished,
51 Running,
52}
53
54#[derive(Clone, Debug, PartialEq, Serialize)]
55pub struct Prompt {
56 pub text: String,
57 pub at: Option<String>,
58}
59
60#[derive(Clone, Debug, PartialEq, Serialize)]
61pub struct ToolCall {
62 pub name: String,
63 pub detail: String,
64 pub status: &'static str,
66 pub at: Option<String>,
67}
68
69#[derive(Clone, Debug, PartialEq, Serialize)]
70pub struct ErrorLine {
71 pub text: String,
72 pub at: Option<String>,
73}
74
75#[derive(Clone, Debug, PartialEq, Serialize)]
76pub struct Summary {
77 pub provider: String,
78 pub provider_session_id: String,
79 pub instance_id: u64,
80 pub workspace_id: String,
81 pub live: bool,
82 pub stopped: Stopped,
83 pub stopped_reason: String,
85 pub last_write_ms: u64,
87 pub prompts: Vec<Prompt>,
88 pub tool_calls: Vec<ToolCall>,
89 pub errors: Vec<ErrorLine>,
90 pub mcp_tools: Vec<String>,
91 pub truncated: bool,
93}
94
95#[derive(Debug, PartialEq)]
97pub struct Refusal(pub u16, pub &'static str);
98
99#[derive(Clone, Copy, Debug, PartialEq)]
101enum Last {
102 Nothing,
103 Prompt,
104 Tool,
105 Answer,
106 TurnEnd,
107 Aborted,
108 Error,
109 Approval,
110}
111
112#[derive(Default)]
113struct Scan {
114 prompts: Vec<Prompt>,
115 tools: Vec<(String, ToolCall)>,
116 errors: Vec<ErrorLine>,
117 mcp: Vec<String>,
118 last: Option<Last>,
119}
120
121impl Scan {
122 fn prompt(&mut self, text: &str, at: Option<String>, r: &Redactor) {
123 let text = r.text(&strip_reminders(text), PROMPT_CHARS);
124 if !text.is_empty() {
125 self.prompts.push(Prompt { text, at });
126 self.last = Some(Last::Prompt);
127 }
128 }
129
130 fn tool(&mut self, id: &str, name: &str, detail: String, at: Option<String>, r: &Redactor) {
131 let name: String = name.chars().take(80).collect();
132 if is_mcp(&name) {
133 self.mcp_tool(&name);
134 }
135 let detail = if mentions_private(&detail) { "[memory or notes path withheld]".to_owned() } else { r.text(&detail, DETAIL_CHARS) };
136 self.tools.push((id.to_owned(), ToolCall { name, detail, status: "pending", at }));
137 self.last = Some(Last::Tool);
138 }
139
140 fn tool_result(&mut self, id: &str, is_error: bool) {
141 if let Some((_, t)) = self.tools.iter_mut().rev().find(|(tid, _)| tid == id) {
142 t.status = if is_error { "error" } else { "ok" };
143 }
144 self.last = Some(Last::Prompt);
145 }
146
147 fn error(&mut self, text: &str, at: Option<String>, r: &Redactor) {
148 self.errors.push(ErrorLine { text: r.text(text, ERROR_CHARS), at });
149 self.last = Some(Last::Error);
150 }
151
152 fn mcp_tool(&mut self, name: &str) {
153 if self.mcp.len() < MCP_TOOLS && !self.mcp.iter().any(|m| m == name) {
154 self.mcp.push(name.to_owned());
155 }
156 }
157}
158
159fn is_mcp(name: &str) -> bool {
160 name.starts_with("mcp__")
161}
162
163pub fn tail(provider_session_id: Option<&str>, instance_id: Option<u64>) -> Result<Summary, Refusal> {
165 let dir = STATE_DIR.get().ok_or(Refusal(503, "the node has no state directory"))?;
166 let entries = crate::pty_resume::load(&crate::pty_resume::path(dir));
167 let gone = crate::session_meta::gone_ids(dir);
168 let homes = provider_homes();
169 tail_in(&entries, &gone, &homes, provider_session_id, instance_id, now_ms())
170}
171
172pub fn tail_in(entries: &[Entry], gone: &[u64], homes: &[PathBuf], provider_session_id: Option<&str>, instance_id: Option<u64>, now: u64) -> Result<Summary, Refusal> {
174 let entry = match (provider_session_id, instance_id) {
175 (Some(sid), _) => entries.iter().find(|e| !e.provider_session_id.is_empty() && e.provider_session_id == sid),
176 (None, Some(i)) => entries.iter().find(|e| e.instance_id == i),
177 (None, None) => return Err(Refusal(400, "provider_session_id or instance_id is required")),
178 }
179 .ok_or(Refusal(404, "the session is not in the node's resume journal"))?;
180 let sid = entry.provider_session_id.as_str();
181 if sid.is_empty() {
182 return Err(Refusal(409, "the session has no provider session id yet"));
183 }
184 if sid.len() > 128 || !sid.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
185 return Err(Refusal(400, "bad provider session id"));
186 }
187 let redactor = Redactor::new(&entry.cwd, homes);
188 let (scan, path, truncated) = match entry.provider.as_str() {
189 "claude" => {
190 let path = claude_transcript(homes, sid).ok_or(Refusal(404, "no transcript for this session"))?;
191 let (head, tail, truncated) = read_ends(&path).map_err(|_| Refusal(500, "the transcript is unreadable"))?;
192 (scan_claude(&head, &tail, &redactor), path, truncated)
193 }
194 "codex" => codex(homes, sid, &redactor)?,
195 _ => return Err(Refusal(501, "unsupported: no session-restore library for this provider")),
196 };
197 let last_write_ms = std::fs::metadata(&path).and_then(|m| m.modified()).ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map_or(0, |d| d.as_millis() as u64);
198 let live = !gone.contains(&entry.instance_id);
199 let (stopped, stopped_reason) = stopped(&scan, live, now.saturating_sub(last_write_ms));
200 let keep_last = |n: usize, len: usize| len.saturating_sub(n);
201 let mut prompts = scan.prompts;
202 prompts.drain(..keep_last(PROMPTS, prompts.len()));
203 let mut tools: Vec<ToolCall> = scan.tools.into_iter().map(|(_, t)| t).collect();
204 tools.drain(..keep_last(TOOL_CALLS, tools.len()));
205 let mut errors = scan.errors;
206 errors.drain(..keep_last(ERRORS, errors.len()));
207 Ok(Summary {
208 provider: entry.provider.clone(),
209 provider_session_id: sid.to_owned(),
210 instance_id: entry.instance_id,
211 workspace_id: entry.workspace_id.clone(),
212 live,
213 stopped,
214 stopped_reason,
215 last_write_ms,
216 prompts,
217 tool_calls: tools,
218 errors,
219 mcp_tools: scan.mcp,
220 truncated,
221 })
222}
223
224fn stopped(scan: &Scan, live: bool, idle_ms: u64) -> (Stopped, String) {
225 let pending = scan.tools.iter().rev().find(|(_, t)| t.status == "pending").map(|(_, t)| t.name.clone());
226 let mins = idle_ms / 60_000;
227 match scan.last.unwrap_or(Last::Nothing) {
228 Last::Error => (Stopped::Crashed, "the last record is an error".into()),
229 Last::TurnEnd | Last::Answer if !live => (Stopped::Finished, "the turn ended and the session is gone".into()),
230 _ if !live => (Stopped::Crashed, "the session is gone in the middle of a turn".into()),
231 Last::TurnEnd => (Stopped::Finished, "the last turn ended".into()),
232 Last::Aborted => (Stopped::Finished, "the last turn was interrupted".into()),
233 Last::Approval => (Stopped::AwaitingApproval, "the provider asked for an approval".into()),
234 Last::Answer if idle_ms >= HUNG_MS => (Stopped::Finished, format!("no write for {mins}m after an answer")),
235 Last::Nothing => (Stopped::Finished, "the transcript has no turn yet".into()),
236 Last::Tool if idle_ms >= HUNG_MS => (Stopped::Hung, format!("tool call {} has no result for {mins}m", pending.unwrap_or_default())),
237 Last::Tool if idle_ms >= APPROVAL_MS => (Stopped::AwaitingApproval, format!("tool call {} waits for a result", pending.unwrap_or_default())),
238 _ if idle_ms >= HUNG_MS => (Stopped::Hung, format!("a turn is open and nothing was written for {mins}m")),
239 _ => (Stopped::Running, "a turn is in progress".into()),
240 }
241}
242
243fn scan_claude(head: &str, tail: &str, r: &Redactor) -> Scan {
244 let mut scan = Scan::default();
245 for line in head.lines() {
247 if let Ok(v) = serde_json::from_str::<Value>(line) {
248 claude_tools_listed(&mut scan, &v);
249 }
250 }
251 let mut lines = tail.lines();
252 if !tail.is_empty() && tail.len() as u64 >= TAIL_BYTES {
253 lines.next(); }
255 for line in lines {
256 let Ok(v) = serde_json::from_str::<Value>(line) else { continue };
257 let at = v["timestamp"].as_str().map(str::to_owned);
258 claude_tools_listed(&mut scan, &v);
259 match v["type"].as_str() {
260 Some("user") if v["isMeta"] != true => match &v["message"]["content"] {
261 Value::String(s) => scan.prompt(s, at, r),
262 Value::Array(blocks) => {
263 for b in blocks {
264 match b["type"].as_str() {
265 Some("text") => scan.prompt(b["text"].as_str().unwrap_or_default(), at.clone(), r),
266 Some("tool_result") => scan.tool_result(b["tool_use_id"].as_str().unwrap_or_default(), b["is_error"] == true),
267 _ => {}
268 }
269 }
270 }
271 _ => {}
272 },
273 Some("assistant") => {
274 if v["isApiErrorMessage"] == true || v["error"].is_string() {
275 let text = v["message"]["content"][0]["text"].as_str().or(v["error"].as_str()).unwrap_or("provider error");
276 scan.error(text, at, r);
277 continue;
278 }
279 for b in v["message"]["content"].as_array().into_iter().flatten() {
280 match b["type"].as_str() {
281 Some("tool_use") => scan.tool(b["id"].as_str().unwrap_or_default(), b["name"].as_str().unwrap_or("?"), tool_detail(&b["input"]), at.clone(), r),
282 Some("text") => scan.last = Some(Last::Answer),
283 _ => {}
284 }
285 }
286 if v["message"]["stop_reason"] == "end_turn" {
287 scan.last = Some(Last::TurnEnd);
288 }
289 }
290 Some("system") => match v["subtype"].as_str() {
291 Some("turn_duration") => scan.last = Some(Last::TurnEnd),
292 _ if v["level"] == "error" || v["subtype"] == "api_error" => {
293 let text = v["error"]["message"].as_str().or(v["content"].as_str()).unwrap_or("system error");
294 scan.error(text, at, r);
295 }
296 _ => {}
297 },
298 _ => {}
299 }
300 }
301 scan
302}
303
304fn claude_tools_listed(scan: &mut Scan, v: &Value) {
305 if v["type"] == "attachment" && v["attachment"]["type"] == "deferred_tools_delta" {
306 for n in v["attachment"]["addedNames"].as_array().into_iter().flatten().filter_map(Value::as_str).filter(|n| is_mcp(n)) {
307 scan.mcp_tool(n);
308 }
309 }
310}
311
312fn tool_detail(input: &Value) -> String {
314 let input = match input {
315 Value::String(s) => serde_json::from_str(s).unwrap_or(Value::String(s.clone())),
316 v => v.clone(),
317 };
318 if let Some(s) = input.as_str() {
319 return s.to_owned();
320 }
321 for k in ["command", "cmd", "file_path", "path", "pattern", "url", "query", "description"] {
322 match &input[k] {
323 Value::String(s) => return s.clone(),
324 Value::Array(a) => return a.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(" "),
325 _ => {}
326 }
327 }
328 String::new()
329}
330
331#[cfg(feature = "session-restore")]
332fn codex(homes: &[PathBuf], sid: &str, r: &Redactor) -> Result<(Scan, PathBuf, bool), Refusal> {
333 use codex_session_restore as csr;
334 let source = codex_homes(homes)
335 .iter()
336 .find_map(|h| csr::resolve_target(h, std::ffi::OsStr::new(sid)).ok())
337 .ok_or(Refusal(404, "no transcript for this session"))?;
338 let limits = csr::RestoreLimits { max_tail_bytes: TAIL_BYTES as usize, max_lines: 20_000, max_messages: 64 };
339 let report = csr::load_session(&source, limits).map_err(|_| Refusal(500, "the transcript is unreadable"))?;
340 let (_, tail, truncated) = read_ends(&source.path).map_err(|_| Refusal(500, "the transcript is unreadable"))?;
341 let mut scan = scan_codex(&tail, r);
342 scan.prompts.clear();
344 scan.errors.clear();
345 for m in &report.messages {
346 match m.role {
347 csr::MessageRole::User => scan.prompts.push(Prompt { text: r.text(&strip_reminders(&m.text), PROMPT_CHARS), at: m.timestamp.clone() }),
348 csr::MessageRole::Error => scan.errors.push(ErrorLine { text: r.text(&m.text, ERROR_CHARS), at: m.timestamp.clone() }),
349 _ => {}
350 }
351 }
352 scan.prompts.retain(|p| !p.text.is_empty());
353 Ok((scan, source.path, truncated || report.truncated))
354}
355
356#[cfg(not(feature = "session-restore"))]
357fn codex(_: &[PathBuf], _: &str, _: &Redactor) -> Result<(Scan, PathBuf, bool), Refusal> {
358 Err(Refusal(501, "unsupported: the node is built without session-restore"))
359}
360
361fn scan_codex(tail: &str, r: &Redactor) -> Scan {
362 let mut scan = Scan::default();
363 for line in tail.lines() {
364 let Ok(v) = serde_json::from_str::<Value>(line) else { continue };
365 let at = v["timestamp"].as_str().map(str::to_owned);
366 let p = &v["payload"];
367 match (v["type"].as_str(), p["type"].as_str()) {
368 (Some("response_item"), Some("function_call" | "custom_tool_call" | "local_shell_call")) => {
369 let input = p.get("arguments").or(p.get("input")).or(p.get("action")).cloned().unwrap_or(Value::Null);
370 scan.tool(p["call_id"].as_str().unwrap_or_default(), p["name"].as_str().unwrap_or("shell"), tool_detail(&input), at, r);
371 }
372 (Some("response_item"), Some("function_call_output" | "custom_tool_call_output")) => {
373 scan.tool_result(p["call_id"].as_str().unwrap_or_default(), codex_failed(&p["output"]));
374 }
375 (Some("response_item"), Some("message")) if p["role"] == "assistant" => scan.last = Some(Last::Answer),
376 (Some("event_msg"), Some("user_message" | "task_started")) => scan.last = Some(Last::Prompt),
377 (Some("event_msg"), Some("agent_message")) => scan.last = Some(Last::Answer),
378 (Some("event_msg"), Some("task_complete")) => scan.last = Some(Last::TurnEnd),
379 (Some("event_msg"), Some("turn_aborted")) => scan.last = Some(Last::Aborted),
380 (Some("event_msg"), Some("error" | "stream_error")) => scan.error(p["message"].as_str().unwrap_or("provider error"), at, r),
381 (Some("event_msg"), Some(k)) if k.ends_with("approval_request") => scan.last = Some(Last::Approval),
382 (Some("event_msg"), Some("mcp_tool_call_begin" | "mcp_tool_call_end")) => {
383 let inv = &p["invocation"];
384 if let (Some(s), Some(t)) = (inv["server"].as_str(), inv["tool"].as_str()) {
385 scan.mcp_tool(&format!("mcp__{s}__{t}"));
386 }
387 }
388 _ => {}
389 }
390 }
391 scan
392}
393
394fn codex_failed(output: &Value) -> bool {
396 let o = match output {
397 Value::String(s) => serde_json::from_str(s).unwrap_or(Value::Null),
398 v => v.clone(),
399 };
400 o["metadata"]["exit_code"].as_i64().is_some_and(|c| c != 0) || o["success"] == false || o["is_error"] == true
401}
402
403fn claude_transcript(homes: &[PathBuf], sid: &str) -> Option<PathBuf> {
404 let name = format!("{sid}.jsonl");
405 homes.iter().find_map(|h| std::fs::read_dir(h.join(".claude/projects")).ok()?.flatten().map(|d| d.path().join(&name)).find(|p| p.is_file()))
406}
407
408#[cfg(feature = "session-restore")]
409fn codex_homes(homes: &[PathBuf]) -> Vec<PathBuf> {
410 let mut out: Vec<PathBuf> = std::env::var_os("CODEX_HOME").filter(|v| !v.is_empty()).map(PathBuf::from).into_iter().collect();
411 out.extend(homes.iter().map(|h| h.join(".codex")));
412 out
413}
414
415fn provider_homes() -> Vec<PathBuf> {
416 let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
417 if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") {
418 homes.extend(std::env::split_paths(&extra));
419 }
420 homes
421}
422
423fn read_ends(path: &Path) -> std::io::Result<(String, String, bool)> {
425 let mut f = std::fs::File::open(path)?;
426 let len = f.metadata()?.len();
427 let mut head = Vec::new();
428 (&mut f).take(HEAD_BYTES.min(len)).read_to_end(&mut head)?;
429 let start = len.saturating_sub(TAIL_BYTES);
430 f.seek(SeekFrom::Start(start))?;
431 let mut tail = Vec::new();
432 f.take(TAIL_BYTES).read_to_end(&mut tail)?;
433 Ok((String::from_utf8_lossy(&head).into_owned(), String::from_utf8_lossy(&tail).into_owned(), start > 0))
434}
435
436fn now_ms() -> u64 {
437 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_millis() as u64)
438}
439
440fn strip_reminders(s: &str) -> String {
442 let mut out = String::new();
443 let mut rest = s;
444 while let Some(i) = rest.find("<system-reminder>") {
445 out.push_str(&rest[..i]);
446 rest = rest[i..].find("</system-reminder>").map_or("", |j| &rest[i + j + "</system-reminder>".len()..]);
447 }
448 out.push_str(rest);
449 out.trim().to_owned()
450}
451
452fn mentions_private(s: &str) -> bool {
454 let l = s.to_ascii_lowercase();
455 ["/memory/", "\\memory\\", "memory.md", "/notes/", "\\notes\\", "notes.md", "claude.md", "agents.md", "/.claude/projects/"].iter().any(|p| l.contains(p))
456}
457
458pub struct Redactor {
460 prefixes: Vec<(String, &'static str)>,
461}
462
463const SECRET_PREFIXES: &[&str] = &["sk-", "ghp_", "gho_", "ghs_", "github_pat_", "glpat-", "xoxb-", "xoxp-", "akia", "g4aho_", "eyj"];
464const SECRET_KEYS: &[&str] = &["token", "secret", "password", "passwd", "api_key", "apikey", "auth", "credential", "seed", "private"];
465
466impl Redactor {
467 pub fn new(cwd: &str, homes: &[PathBuf]) -> Redactor {
468 let mut prefixes: Vec<(String, &'static str)> = Vec::new();
469 if cwd.len() > 1 {
470 prefixes.push((cwd.trim_end_matches(['/', '\\']).to_owned(), "<workspace>"));
471 }
472 for h in homes {
473 let h = h.to_string_lossy().trim_end_matches(['/', '\\']).to_owned();
474 if h.len() > 1 {
475 prefixes.push((h, "~"));
476 }
477 }
478 prefixes.sort_by_key(|(p, _)| std::cmp::Reverse(p.len()));
479 Redactor { prefixes }
480 }
481
482 pub fn text(&self, s: &str, max: usize) -> String {
484 let mut s: String = s.split_whitespace().collect::<Vec<_>>().join(" ");
485 for (p, with) in &self.prefixes {
486 s = s.replace(p.as_str(), with);
487 }
488 let words: Vec<String> = s.split(' ').map(|w| self.word(w)).collect();
489 let mut out = words.join(" ");
490 if out.chars().count() > max {
491 out = out.chars().take(max).collect::<String>() + "…";
492 }
493 out
494 }
495
496 fn word(&self, w: &str) -> String {
497 if let Some((k, v)) = w.split_once(['=', ':']).filter(|(_, v)| !v.is_empty() && !v.starts_with("//")) {
499 let kl = k.to_ascii_lowercase();
500 if SECRET_KEYS.iter().any(|s| kl.contains(s)) {
501 return format!("{k}=[redacted]");
502 }
503 if looks_secret(v.trim_matches(['"', '\''])) {
504 return format!("{k}=[redacted]");
505 }
506 }
507 let bare = w.trim_matches(|c: char| "\"'`(),;[]{}".contains(c));
508 if looks_secret(bare) {
509 return w.replace(bare, "[redacted]");
510 }
511 if let Some(short) = host_path(bare) {
512 return w.replace(bare, &short);
513 }
514 w.to_owned()
515 }
516}
517
518fn looks_secret(v: &str) -> bool {
519 let l = v.to_ascii_lowercase();
520 if v.len() >= 12 && SECRET_PREFIXES.iter().any(|p| l.starts_with(p)) {
521 return true;
522 }
523 v.len() >= 32
525 && v.chars().all(|c| c.is_ascii_alphanumeric() || "+/_=-".contains(c))
526 && v.chars().any(|c| c.is_ascii_uppercase())
527 && v.chars().any(|c| c.is_ascii_lowercase())
528 && v.chars().any(|c| c.is_ascii_digit())
529}
530
531fn host_path(w: &str) -> Option<String> {
533 let windows = w.len() > 3 && w.as_bytes()[1] == b':' && (w.as_bytes()[2] == b'\\' || w.as_bytes()[2] == b'/');
534 if !(w.starts_with('/') && w[1..].contains('/')) && !windows {
535 return None;
536 }
537 let last = w.trim_end_matches(['/', '\\']).rsplit(['/', '\\']).next().unwrap_or_default();
538 Some(format!("<host>/{last}"))
539}
540
541pub fn reply(req: &Value) -> Value {
543 let sid = req["provider_session_id"].as_str();
544 let inst = req["instance_id"].as_u64();
545 match tail(sid, inst) {
546 Ok(s) => json!({ "ok": true, "body": s }),
547 Err(Refusal(status, message)) => json!({ "ok": false, "status": status, "message": message }),
548 }
549}
550
551#[cfg(test)]
552mod tests {
553 use super::*;
554 use std::io::Write;
555
556 const SID: &str = "0b9c3a52-1111-4222-8333-944455556666";
557
558 fn entry(provider: &str, inst: u64, sid: &str, cwd: &str) -> Entry {
559 Entry { instance_id: inst, provider: provider.into(), workspace_id: "task-7".into(), provider_session_id: sid.into(), cwd: cwd.into(), acp: true, ..Default::default() }
560 }
561
562 fn write(path: &Path, lines: &[Value]) {
563 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
564 let mut f = std::fs::File::create(path).unwrap();
565 for l in lines {
566 writeln!(f, "{l}").unwrap();
567 }
568 }
569
570 fn claude_home(lines: &[Value]) -> tempfile::TempDir {
571 let home = tempfile::tempdir().unwrap();
572 write(&home.path().join(".claude/projects/-w/").join(format!("{SID}.jsonl")), lines);
573 home
574 }
575
576 fn tool_use(id: &str, name: &str, input: Value) -> Value {
577 json!({"type": "assistant", "timestamp": "t2", "message": {"content": [{"type": "tool_use", "id": id, "name": name, "input": input}]}})
578 }
579
580 fn tool_result(id: &str, is_error: bool) -> Value {
581 json!({"type": "user", "message": {"content": [{"type": "tool_result", "tool_use_id": id, "is_error": is_error, "content": "SECRET OUTPUT"}]}})
582 }
583
584 #[test]
585 fn claude_tail_reads_prompts_tools_errors_and_mcp_without_private_text() {
586 let home = claude_home(&[
587 json!({"type": "attachment", "attachment": {"type": "deferred_tools_delta", "addedNames": ["Bash", "mcp__m4a__send", "mcp__m4a__inbox"]}}),
588 json!({"type": "user", "timestamp": "t1", "message": {"content": "<system-reminder>memory: the owner's notes</system-reminder>fix the build in /w/task-7/src"}}),
589 json!({"type": "user", "isMeta": true, "message": {"content": "meta"}}),
590 tool_use("a", "Bash", json!({"command": "cargo test GITHUB_TOKEN=ghp_abcdefghijklmnop1234"})),
591 tool_result("a", true),
592 tool_use("b", "Read", json!({"file_path": "/home/u/.claude/projects/x/memory/MEMORY.md"})),
593 tool_result("b", false),
594 tool_use("c", "mcp__m4a__send", json!({"to": "hatcheryweb"})),
595 json!({"type": "system", "level": "error", "subtype": "api_error", "error": {"message": "overloaded at /opt/secret/dir/x.log"}}),
596 ]);
597 let entries = [entry("claude", 3, SID, "/w/task-7")];
598 let s = tail_in(&entries, &[], &[home.path().to_path_buf()], Some(SID), None, now_ms()).unwrap();
599 assert_eq!(s.prompts, vec![Prompt { text: "fix the build in <workspace>/src".into(), at: Some("t1".into()) }]);
600 let tools: Vec<_> = s.tool_calls.iter().map(|t| (t.name.as_str(), t.status, t.detail.as_str())).collect();
601 assert_eq!(tools[0], ("Bash", "error", "cargo test GITHUB_TOKEN=[redacted]"));
602 assert_eq!(tools[1], ("Read", "ok", "[memory or notes path withheld]"));
603 assert_eq!((tools[2].0, tools[2].1), ("mcp__m4a__send", "pending"));
604 assert_eq!(s.errors[0].text, "overloaded at <host>/x.log");
605 assert_eq!(s.mcp_tools, ["mcp__m4a__send", "mcp__m4a__inbox"]);
606 assert_eq!((s.stopped, s.live, s.instance_id), (Stopped::Crashed, true, 3));
607 let all = serde_json::to_string(&s).unwrap();
608 for leak in ["SECRET OUTPUT", "owner's notes", "ghp_", "/opt/secret", "MEMORY.md"] {
609 assert!(!all.contains(leak), "{leak} leaked: {all}");
610 }
611 }
612
613 #[test]
614 fn where_it_stopped() {
615 let sc = |last: Last, pending: bool| {
616 let mut s = Scan { last: Some(last), ..Default::default() };
617 if pending {
618 s.tools.push(("x".into(), ToolCall { name: "Bash".into(), detail: String::new(), status: "pending", at: None }));
619 }
620 s
621 };
622 let st = |s: &Scan, live: bool, idle: u64| stopped(s, live, idle).0;
623 assert_eq!(st(&sc(Last::TurnEnd, false), true, 5), Stopped::Finished);
624 assert_eq!(st(&sc(Last::TurnEnd, false), false, 5), Stopped::Finished);
625 assert_eq!(st(&sc(Last::Tool, true), false, 5), Stopped::Crashed, "gone mid-turn");
626 assert_eq!(st(&sc(Last::Error, false), true, 5), Stopped::Crashed);
627 assert_eq!(st(&sc(Last::Tool, true), true, 5_000), Stopped::Running);
628 assert_eq!(st(&sc(Last::Tool, true), true, 60_000), Stopped::AwaitingApproval);
629 assert_eq!(st(&sc(Last::Approval, false), true, 1), Stopped::AwaitingApproval);
630 assert_eq!(st(&sc(Last::Tool, true), true, HUNG_MS), Stopped::Hung);
631 assert_eq!(st(&sc(Last::Prompt, false), true, HUNG_MS + 1), Stopped::Hung);
632 assert_eq!(st(&sc(Last::Prompt, false), true, 10), Stopped::Running);
633 assert!(stopped(&sc(Last::Tool, true), true, HUNG_MS).1.contains("Bash"));
634 }
635
636 #[test]
637 fn found_only_through_the_resume_journal() {
638 let home = claude_home(&[json!({"type": "user", "message": {"content": "hi"}})]);
639 let homes = [home.path().to_path_buf()];
640 let entries = [entry("claude", 3, SID, "/w"), entry("grok", 4, "g-1", "/w"), entry("claude", 5, "", "/w"), entry("claude", 6, "../../etc/passwd", "/w")];
641 let t = |sid: Option<&str>, inst: Option<u64>| tail_in(&entries, &[3], &homes, sid, inst, 0);
642 let by_instance = t(None, Some(3)).unwrap();
643 assert_eq!((by_instance.provider_session_id.as_str(), by_instance.live), (SID, false));
644 assert_eq!(t(Some("not-journalled"), None).unwrap_err(), Refusal(404, "the session is not in the node's resume journal"));
645 assert_eq!(t(None, None).unwrap_err().0, 400);
646 assert_eq!(t(Some("g-1"), None).unwrap_err().0, 501, "grok: unsupported");
647 assert_eq!(t(None, Some(5)).unwrap_err().0, 409);
648 assert_eq!(t(None, Some(6)).unwrap_err().0, 400, "no path tricks");
649 }
650
651 #[test]
652 fn redaction_keeps_commit_ids_and_drops_secrets_and_paths() {
653 let r = Redactor::new("/srv/ws/task-7", &[PathBuf::from("/home/u")]);
654 assert_eq!(r.text("git show 0123456789abcdef0123456789abcdef01234567", 200), "git show 0123456789abcdef0123456789abcdef01234567");
655 assert_eq!(r.text("export ANTHROPIC_API_KEY=xyz", 200), "export ANTHROPIC_API_KEY=[redacted]");
656 assert_eq!(r.text("Authorization: Bearer abcDEF0123456789abcDEF0123456789xyz", 200), "Authorization: Bearer [redacted]");
657 assert_eq!(r.text("cat /srv/ws/task-7/a.rs ~/x /home/u/.codex/config.toml /etc/hosts", 200), "cat <workspace>/a.rs ~/x ~/.codex/config.toml <host>/hosts");
658 assert_eq!(r.text("see https://example.com/a/b", 200), "see https://example.com/a/b");
659 assert_eq!(r.text(&"x".repeat(50), 10), format!("{}…", "x".repeat(10)));
660 assert_eq!(strip_reminders("a<system-reminder>b</system-reminder>c<system-reminder>open"), "ac");
661 }
662
663 #[cfg(feature = "session-restore")]
664 #[test]
665 fn codex_tail_through_session_restore() {
666 let home = tempfile::tempdir().unwrap();
667 let rollout = home.path().join(".codex/sessions/2026/10/19").join(format!("rollout-2026-10-19T10-00-00-{SID}.jsonl"));
668 let rec = |ty: &str, payload: Value| json!({"timestamp": "2026-10-19T10:00:01Z", "type": ty, "payload": payload});
669 write(&rollout, &[
670 rec("session_meta", json!({"id": SID, "cwd": "/w/task-7", "model_provider": "openai"})),
671 rec("event_msg", json!({"type": "user_message", "message": "why is ci red"})),
672 rec("response_item", json!({"type": "message", "role": "user", "content": [{"type": "input_text", "text": "why is ci red"}]})),
673 rec("response_item", json!({"type": "function_call", "name": "shell", "arguments": "{\"command\":[\"bash\",\"-lc\",\"cargo test\"]}", "call_id": "c1"})),
674 rec("response_item", json!({"type": "function_call_output", "call_id": "c1", "output": "{\"output\":\"boom\",\"metadata\":{\"exit_code\":101}}"})),
675 rec("event_msg", json!({"type": "mcp_tool_call_end", "invocation": {"server": "m4a", "tool": "send"}})),
676 rec("response_item", json!({"type": "function_call", "name": "shell", "arguments": "{\"command\":[\"bash\",\"-lc\",\"git push\"]}", "call_id": "c2"})),
677 rec("event_msg", json!({"type": "exec_approval_request", "call_id": "c2"})),
678 ]);
679 let entries = [entry("codex", 21, SID, "/w/task-7")];
680 let s = tail_in(&entries, &[], &[home.path().to_path_buf()], Some(SID), None, now_ms()).unwrap();
681 assert_eq!(s.stopped, Stopped::AwaitingApproval);
682 let tools: Vec<_> = s.tool_calls.iter().map(|t| (t.detail.as_str(), t.status)).collect();
683 assert_eq!(tools, [("bash -lc cargo test", "error"), ("bash -lc git push", "pending")]);
684 assert_eq!(s.mcp_tools, ["mcp__m4a__send"]);
685 assert!(s.prompts.iter().any(|p| p.text == "why is ci red"), "{:?}", s.prompts);
686 assert!(!serde_json::to_string(&s).unwrap().contains("boom"), "no tool output");
687 }
688}