Skip to main content

hatchery_node/
session_tail.rs

1//! Tail summary of a session's provider transcript, for a human or operator who cannot reach
2//! the machine (a session went silent: what did it do last, where did it stop?).
3//!
4//! The session is found in the resume journal (`session-resume.json`) by its provider session
5//! id (or its instance); the transcript is the provider's own file: claude
6//! `~/.claude/projects/*/<id>.jsonl`, codex `~/.codex/sessions/**/rollout-*-<id>.jsonl`
7//! (located and read through `codex-session-restore`). Other providers answer `unsupported`.
8//!
9//! The summary holds only recent prompts, tool calls with their status, errors, the MCP tools
10//! the session had and where it stopped. Never tool output, env, memory or notes: text inside
11//! `<system-reminder>` is dropped, a tool call naming a memory or notes path keeps only its
12//! name, secrets are scrubbed and host paths shortened. Access is decided by core (owner of the
13//! node or an `operator` grant on it); the node answers only core's own link.
14
15use 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
22/// Bytes read from the end (and the start, for the tools list) of a transcript.
23const 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;
32/// A pending tool call this long without a result reads as an approval wait; past
33/// [`HUNG_MS`] without any transcript write the session reads as hung.
34const APPROVAL_MS: u64 = 30_000;
35const HUNG_MS: u64 = 10 * 60_000;
36
37static STATE_DIR: OnceLock<PathBuf> = OnceLock::new();
38
39/// The node's state directory (where the resume journal lives); set once at start.
40pub 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    /// `ok` | `error` | `pending`
65    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    /// Why `stopped` reads so (read off the transcript, not the process).
84    pub stopped_reason: String,
85    /// Last transcript write, unix ms.
86    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    /// Only the transcript's tail was read.
92    pub truncated: bool,
93}
94
95/// Why no summary: `(status, message)` for core's reply.
96#[derive(Debug, PartialEq)]
97pub struct Refusal(pub u16, pub &'static str);
98
99/// What the transcript said last, oldest to newest folded into one state.
100#[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
163/// The summary for the journalled session with `provider_session_id` (or `instance_id`).
164pub 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
172/// [`tail`] over explicit inputs (tests).
173pub 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    // The tools list is announced near the start; the tail holds the recent work.
246    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(); // cut mid-record
254    }
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
312/// The key argument of a tool call: its command, path, pattern or URL.
313fn 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    // Prompts and errors as session-restore reads them (bounded, redacted), not our scan.
343    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
394/// A codex tool output failed: a non-zero exit code in its metadata, or an error flag.
395fn 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
423/// The file's first [`HEAD_BYTES`] and last [`TAIL_BYTES`]; `true` when the tail is not all.
424fn 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
440/// Text without `<system-reminder>` blocks (memory, notes and context the harness injects).
441fn 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
452/// A path that belongs to an agent's memory or notes.
453fn 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
458/// Scrubs secrets and host paths out of text shown off the machine.
459pub 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    /// One line of at most `max` chars, secrets and host paths replaced.
483    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        // KEY=VALUE / KEY: VALUE with a secret-looking key.
498        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    // Long mixed-case base64/token-like strings (lowercase hex such as commit ids stays).
524    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
531/// An absolute host path left after the workspace/home prefixes: only its last name stays.
532fn 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
541/// The `{"ok", "body"|"status","message"}` reply for core.
542pub 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}