From bc615cb16630d057bbefaca333fd2fafa914d9a2 Mon Sep 17 00:00:00 2001 From: aayank13 Date: Sat, 10 Oct 2026 15:16:21 +0530 Subject: [PATCH] feat: support codex, gemini cli and eight more coding agents --- README.md | 62 +- crates/trodden-capture/src/builder.rs | 479 ++++++++ crates/trodden-capture/src/claude_code.rs | 485 ++------ crates/trodden-capture/src/codex.rs | 1361 +++++++++++++++++++++ crates/trodden-capture/src/copilot.rs | 776 ++++++++++++ crates/trodden-capture/src/diff.rs | 137 +++ crates/trodden-capture/src/droid.rs | 727 +++++++++++ crates/trodden-capture/src/gemini.rs | 935 ++++++++++++++ crates/trodden-capture/src/journal.rs | 537 ++++++++ crates/trodden-capture/src/kimi.rs | 1 + crates/trodden-capture/src/lib.rs | 10 + crates/trodden-capture/src/qwen.rs | 672 ++++++++++ crates/trodden-redact/src/lib.rs | 24 +- crates/trodden-store/src/ingest.rs | 7 + crates/trodden-store/src/learning.rs | 48 +- crates/trodden-store/src/schema.rs | 3 + crates/trodden/src/cli.rs | 229 +++- crates/trodden/src/connect.rs | 469 +++++++ crates/trodden/src/harness.rs | 259 ++-- crates/trodden/src/harness/claude_code.rs | 486 ++++++++ crates/trodden/src/harness/cline.rs | 956 +++++++++++++++ crates/trodden/src/harness/codex.rs | 562 +++++++++ crates/trodden/src/harness/copilot.rs | 626 ++++++++++ crates/trodden/src/harness/cursor.rs | 816 ++++++++++++ crates/trodden/src/harness/droid.rs | 565 +++++++++ crates/trodden/src/harness/gemini.rs | 614 ++++++++++ crates/trodden/src/harness/kimi.rs | 792 ++++++++++++ crates/trodden/src/harness/opencode.rs | 825 +++++++++++++ crates/trodden/src/harness/qwen.rs | 492 ++++++++ crates/trodden/src/home.rs | 50 + crates/trodden/src/hook.rs | 243 ++-- crates/trodden/src/ingest.rs | 63 +- crates/trodden/src/lib.rs | 4 +- 33 files changed, 13671 insertions(+), 644 deletions(-) create mode 100644 crates/trodden-capture/src/builder.rs create mode 100644 crates/trodden-capture/src/codex.rs create mode 100644 crates/trodden-capture/src/copilot.rs create mode 100644 crates/trodden-capture/src/diff.rs create mode 100644 crates/trodden-capture/src/droid.rs create mode 100644 crates/trodden-capture/src/gemini.rs create mode 100644 crates/trodden-capture/src/journal.rs create mode 100644 crates/trodden-capture/src/kimi.rs create mode 100644 crates/trodden-capture/src/qwen.rs create mode 100644 crates/trodden/src/connect.rs create mode 100644 crates/trodden/src/harness/claude_code.rs create mode 100644 crates/trodden/src/harness/cline.rs create mode 100644 crates/trodden/src/harness/codex.rs create mode 100644 crates/trodden/src/harness/copilot.rs create mode 100644 crates/trodden/src/harness/cursor.rs create mode 100644 crates/trodden/src/harness/droid.rs create mode 100644 crates/trodden/src/harness/gemini.rs create mode 100644 crates/trodden/src/harness/kimi.rs create mode 100644 crates/trodden/src/harness/opencode.rs create mode 100644 crates/trodden/src/harness/qwen.rs diff --git a/README.md b/README.md index c6b3084..e3327f2 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,14 @@ passed) and gives them back to the agent the next time a similar task comes up. It is one Rust binary. It runs locally, makes no network calls and needs no -account. It works with Claude Code today. +account. It works with Claude Code, Codex, Gemini CLI, Qwen Code, GitHub +Copilot CLI, Factory Droid, Cursor, OpenCode, Kilo Code, Kimi Code and Cline. ## How it works -1. **Capture.** When a turn or session ends, Trodden reads the transcript. +1. **Capture.** When a turn or session ends, Trodden reads the transcript + (for agents that do not keep one with command results, it follows the + session through the agent's hooks instead). It keeps the first line of each prompt (up to 200 characters), commands, file paths, touched function names and exit codes, with secrets redacted. From a failed tool call it keeps at most one normalized error line, used @@ -45,23 +48,64 @@ Task: Page 2 of the product listing repeats the last product from page 1 ## Install -Requires Rust and Claude Code. +Requires Rust. ```sh git clone https://github.com/aayank13/trodden && cd trodden cargo install --locked --path crates/trodden trodden init -claude plugin marketplace add aayank13/trodden -claude plugin install trodden@trodden ``` +Then connect your agents. `trodden connect ` adds Trodden's hooks to +the agent's user settings, next to any hooks you already have +(`trodden connect` alone lists the agents and which are installed; +`--dry-run` shows the change first; `trodden disconnect ` removes it): + +```sh +trodden connect claude-code +trodden connect codex +``` + +Claude Code can also load the same hooks as a plugin instead +(`claude plugin marketplace add aayank13/trodden`, then +`claude plugin install trodden@trodden`); use one or the other. + Optional: ```sh trodden embeddings install # semantic matching, a one-time 32 MB download -trodden backfill # learn from your existing Claude Code sessions +trodden backfill # learn from your existing sessions ``` +## Agents + +| Agent | `connect` name | Recall on prompt | Recall on a failed command | Verify reminder | Learns from | Backfill | +|---|---|---|---|---|---|---| +| Claude Code | `claude-code` | yes | yes | yes | transcript | yes | +| Codex | `codex` | yes | yes | yes | transcript | yes | +| Gemini CLI | `gemini` | yes | yes | yes | transcript | yes | +| Qwen Code | `qwen` | yes | yes | yes | transcript | yes | +| GitHub Copilot CLI | `copilot` | yes | yes | yes | transcript | yes | +| Factory Droid | `droid` | yes | yes | yes | transcript | yes | +| Cursor | `cursor` | no | yes | yes | hooks | no | +| OpenCode | `opencode` | yes | yes | no | hooks | no | +| Kilo Code | `kilo` | yes | yes | no | hooks | no | +| Kimi Code | `kimi` | yes | no | yes | hooks | no | +| Cline | `cline` | yes | yes | no | hooks | no | + +"no" means the agent's hooks cannot do it: Cursor cannot add context to a +prompt, Kimi Code ignores output after a failed command, and OpenCode, Kilo +Code and Cline cannot continue a finished turn. Agents that learn from hooks +only learn from sessions after they are connected. + +A few agents need one step of their own after `trodden connect`: Codex asks +you to trust the new hooks the next time it starts, Cline needs its "Enable +Hooks" setting turned on, and running Factory Droid, OpenCode and Kilo Code +sessions must be restarted. `trodden connect` prints the step. + +Agents with MCP support but no usable hooks can still ask Trodden directly +through `trodden mcp`. + ## Commands | Command | What it does | @@ -78,13 +122,15 @@ trodden backfill # learn from your existing Claude Code sessions | `trodden config` | Show the current settings | | `trodden config holdout <0-1 or auto>` | Share of matches held out for comparison | | `trodden config verify-reminder ` | Ask the agent to run a recalled procedure's check when it skipped it | +| `trodden connect []` / `disconnect ` | Install or remove an agent's hooks | +| `trodden backfill [--agent ]` | Learn from sessions already on disk | | `trodden doctor` | Check the installation | | `trodden mcp` | Read-only MCP tools for agents without prompt hooks | ## Status -Pre-alpha. Formats and commands may change between versions. Support for -Codex and other agents, and prebuilt binaries, come next. +Pre-alpha. Formats and commands may change between versions. Prebuilt +binaries come next. ## License diff --git a/crates/trodden-capture/src/builder.rs b/crates/trodden-capture/src/builder.rs new file mode 100644 index 0000000..e361d50 --- /dev/null +++ b/crates/trodden-capture/src/builder.rs @@ -0,0 +1,479 @@ +use std::collections::HashMap; + +use anyhow::{Result, bail}; +use jiff::Timestamp; +use trodden_core::{ + HarnessId, SessionId, Trace, + trace::{Event, EventKind, FileChange, ToolAction, ToolArgs, ToolCall, ToolOutcome}, +}; +use trodden_redact::Redactor; + +use crate::{ + ErrorSignature, + command::Command, + diff::Diff, + evidence::CheckOutput, + symbols::{Hunk, SymbolFinder}, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Finish { + Succeeded, + Failed { exit_code: Option }, + Interrupted, +} + +#[derive(Debug)] +pub struct Reminder; + +impl Reminder { + pub const PREFIX: &'static str = "Trodden: the procedure recalled"; +} + +#[derive(Debug)] +pub(crate) struct Prompts { + pub(crate) skipped: &'static [&'static str], + pub(crate) pasted: Option<&'static str>, +} + +pub(crate) struct TraceBuilder<'a> { + harness: &'static str, + redactor: &'a Redactor, + session: Option, + cwd: String, + here: String, + model: Option, + started_at: Option, + now: Timestamp, + events: Vec, + pending: HashMap, +} + +impl<'a> TraceBuilder<'a> { + pub(crate) const SUMMARY_CHARS: usize = 200; + + pub(crate) const SUMMARY_SOURCE_BYTES: usize = 64 * 1024; + + pub(crate) const REDACTED: &'static str = "[REDACTED:"; + + const SEPARATORS: &'static [char] = &['/', '\\']; + + pub(crate) fn new(harness: &'static str, redactor: &'a Redactor) -> Self { + Self { + harness, + redactor, + session: None, + cwd: String::new(), + here: String::new(), + model: None, + started_at: None, + now: Timestamp::UNIX_EPOCH, + events: Vec::new(), + pending: HashMap::new(), + } + } + + pub(crate) fn session(&mut self, id: &str) { + if self.session.is_none() && !id.is_empty() { + self.session = Some(id.to_owned()); + } + } + + pub(crate) fn directory(&mut self, cwd: &str) { + let cwd = cwd.trim_end_matches(Self::SEPARATORS); + if cwd.is_empty() { + return; + } + if self.cwd.is_empty() { + cwd.clone_into(&mut self.cwd); + } + cwd.clone_into(&mut self.here); + } + + pub(crate) fn working_directory(&self) -> &str { + &self.cwd + } + + pub(crate) fn time(&mut self, at: &str) { + if let Ok(at) = at.parse::() { + self.time_at(at); + } + } + + pub(crate) fn time_at(&mut self, at: Timestamp) { + self.now = at; + self.started_at.get_or_insert(at); + } + + pub(crate) fn model(&mut self, model: &str) { + if self.model.is_none() && !model.is_empty() && !model.starts_with('<') { + self.model = Some(model.to_owned()); + } + } + + pub(crate) fn prompt(&mut self, text: &str, prompts: &Prompts) { + if let Some(summary) = Self::summarize(text, prompts, self.redactor) { + self.emit(EventKind::Prompt { summary }); + } + } + + pub(crate) fn summarize(text: &str, prompts: &Prompts, redactor: &Redactor) -> Option { + let text = text.trim(); + if text.is_empty() + || text.starts_with(Reminder::PREFIX) + || prompts + .skipped + .iter() + .any(|prefix| text.starts_with(prefix)) + { + return None; + } + let headline = Self::headline(text, prompts); + (!headline.is_empty()).then(|| Self::summary(headline, redactor)) + } + + fn headline<'t>(text: &'t str, prompts: &Prompts) -> &'t str { + let mut wrapper: Option<&str> = None; + let mut pasted = None; + for line in text.lines() { + let mut rest = line.trim(); + while !rest.is_empty() { + if let Some(tag) = wrapper { + let Some(close) = rest.find(&format!(" Option<&'static str> { + prompts + .skipped + .iter() + .chain(prompts.pasted.as_ref()) + .map(|prefix| prefix.trim_end_matches('>')) + .filter(|tag| tag.starts_with('<')) + .find(|tag| line.starts_with(tag)) + } + + fn after_tag(text: &str) -> &str { + text.find('>').map_or("", |end| text[end + 1..].trim()) + } + + fn summary(line: &str, redactor: &Redactor) -> String { + let source = if line.len() > Self::SUMMARY_SOURCE_BYTES { + line[..line.floor_char_boundary(Self::SUMMARY_SOURCE_BYTES)] + .trim_end_matches(|c: char| !c.is_whitespace()) + } else { + line + }; + let redacted = redactor.redact(source); + Self::truncate(&redacted, Self::SUMMARY_CHARS) + .trim_end() + .to_owned() + } + + fn truncate(text: &str, chars: usize) -> &str { + let Some((cut, _)) = text.char_indices().nth(chars) else { + return text; + }; + let straddling = text + .match_indices(Self::REDACTED) + .map(|(start, _)| start) + .take_while(|&start| start < cut) + .last() + .filter(|&start| text[start..].find(']').is_none_or(|end| start + end >= cut)); + &text[..straddling.unwrap_or(cut)] + } + + pub(crate) fn shell(&self, command: &str) -> (ToolArgs, ToolAction) { + let subdirectory = self.subdirectory(); + let base = if subdirectory.is_some() { + &self.here + } else { + &self.cwd + }; + let command = Command::normalize(command, base); + let text = match subdirectory { + Some(dir) if !command.text().is_empty() => { + format!("cd {} && {}", Self::quoted(dir), command.text()) + } + _ => command.text().to_owned(), + }; + let args = ToolArgs { + command: Some(self.redactor.redact(&text).into_owned()), + ..ToolArgs::default() + }; + (args, command.action()) + } + + pub(crate) fn redact(&self, text: &str) -> String { + self.redactor.redact(text).into_owned() + } + + pub(crate) fn search_pattern(&self, pattern: &str, glob: bool) -> String { + if glob && Self::is_absolute(pattern) { + self.relative(pattern) + } else { + self.redact(pattern) + } + } + + pub(crate) fn call( + &mut self, + id: Option<&str>, + tool: &str, + action: ToolAction, + args: ToolArgs, + ) -> usize { + let index = self.emit(EventKind::ToolCall(ToolCall { + tool: tool.to_owned(), + action, + args, + outcome: ToolOutcome::Interrupted, + changes: Vec::new(), + duration_ms: None, + error: None, + })); + if let Some(id) = id { + self.pending.insert(id.to_owned(), index); + } + index + } + + pub(crate) fn is_pending(&self, id: &str) -> bool { + self.pending.contains_key(id) + } + + pub(crate) fn pending_call(&self, id: &str) -> Option<&ToolCall> { + let index = *self.pending.get(id)?; + match &self.events[index].kind { + EventKind::ToolCall(call) => Some(call), + _ => None, + } + } + + // A masked exit code (`| tail`, `|| true`) leaves the outcome to the check's own summary + // line. + pub(crate) fn resolve( + &mut self, + id: &str, + finish: Finish, + output: &str, + duration_ms: Option, + changes: Vec, + ) { + let Some(index) = self.pending.remove(id) else { + return; + }; + let redactor = self.redactor; + let EventKind::ToolCall(call) = &mut self.events[index].kind else { + return; + }; + let verdict = call + .args + .command + .as_deref() + .and_then(|command| CheckOutput::verdict(command, output)); + call.outcome = match (finish, verdict) { + (Finish::Interrupted, _) => ToolOutcome::Interrupted, + (Finish::Failed { .. }, Some(true)) => ToolOutcome::Succeeded, + (Finish::Failed { exit_code }, _) => ToolOutcome::Failed { exit_code }, + (Finish::Succeeded, Some(false)) => ToolOutcome::Failed { exit_code: None }, + (Finish::Succeeded, _) => ToolOutcome::Succeeded, + }; + if matches!(call.outcome, ToolOutcome::Failed { .. }) { + call.error = ErrorSignature::of(output, redactor); + } + call.duration_ms = duration_ms; + if call.action == ToolAction::Edit && !matches!(finish, Finish::Failed { .. }) { + call.changes = changes; + } + } + + pub(crate) fn change( + &self, + path: &str, + created: bool, + diff: &Diff, + original: Option<&str>, + ) -> FileChange { + let hunks: Vec = diff + .starts + .iter() + .map(|&old_start| Hunk { old_start }) + .collect(); + FileChange { + path: self.relative(path), + created, + symbols: SymbolFinder::find(diff.changed.iter().map(String::as_str), original, &hunks), + lines_added: diff.added, + lines_removed: diff.removed, + } + } + + pub(crate) fn compaction(&mut self, automatic: bool) { + self.emit(EventKind::Compaction { automatic }); + } + + pub(crate) fn emit(&mut self, kind: EventKind) -> usize { + let seq = u32::try_from(self.events.len()).unwrap_or(u32::MAX); + self.events.push(Event { + seq, + at: self.now, + kind, + }); + self.events.len() - 1 + } + + pub(crate) fn finish(self, missing: &str) -> Result { + let Some(session) = self.session else { + bail!("{missing}"); + }; + Ok(Trace { + session: SessionId::new(session), + harness: HarnessId::new(self.harness), + model: self.model, + cwd: self.redactor.redact(&self.cwd).into_owned(), + commit: None, + started_at: self.started_at.unwrap_or(self.now), + events: self.events, + }) + } + + pub(crate) fn relative(&self, path: &str) -> String { + let (base, rest) = if Self::is_absolute(path) { + match self.below(path) { + Some(rest) => ("", rest), + None => return self.redactor.redact(path).into_owned(), + } + } else { + (self.subdirectory().unwrap_or_default(), path) + }; + let separators = self.separators(); + let parts = Self::normalized(base.split(separators).chain(rest.split(separators))); + let text = if parts.first() == Some(&"..") && !self.cwd.is_empty() { + let outside = self.outside(&parts); + match self.below(&outside) { + Some(rest) => Self::joined(&Self::normalized(rest.split(separators))), + None => outside, + } + } else { + Self::joined(&parts) + }; + self.redactor.redact(&text).into_owned() + } + + fn joined(parts: &[&str]) -> String { + if parts.is_empty() { + ".".to_owned() + } else { + parts.join("/") + } + } + + fn outside(&self, parts: &[&str]) -> String { + let root = Self::root(&self.cwd); + let separator = if self.cwd.contains('\\') { "\\" } else { "/" }; + let resolved = Self::normalized( + self.cwd[root.len()..] + .split(self.separators()) + .chain(parts.iter().copied()), + ); + let below_root: Vec<&str> = resolved + .into_iter() + .skip_while(|part| *part == "..") + .collect(); + format!("{root}{}", below_root.join(separator)) + } + + fn below<'p>(&self, path: &'p str) -> Option<&'p str> { + if self.cwd.is_empty() { + return None; + } + let head = path.get(..self.cwd.len())?; + let same = if self.is_windows() { + head.chars().zip(self.cwd.chars()).all(|(a, b)| { + a.eq_ignore_ascii_case(&b) + || (Self::SEPARATORS.contains(&a) && Self::SEPARATORS.contains(&b)) + }) + } else { + head == self.cwd + }; + let rest = &path[self.cwd.len()..]; + (same && (rest.is_empty() || rest.starts_with(self.separators()))) + .then(|| rest.trim_start_matches(self.separators())) + } + + fn subdirectory(&self) -> Option<&str> { + self.below(&self.here).filter(|dir| !dir.is_empty()) + } + + fn is_windows(&self) -> bool { + Self::has_drive(&self.cwd) || self.cwd.starts_with(r"\\") + } + + fn separators(&self) -> &'static [char] { + if self.is_windows() { + Self::SEPARATORS + } else { + &Self::SEPARATORS[..1] + } + } + + pub(crate) fn is_absolute(path: &str) -> bool { + path.starts_with(['~', '/', '\\']) + || (Self::has_drive(path) && path[2..].starts_with(Self::SEPARATORS)) + } + + fn has_drive(path: &str) -> bool { + matches!(path.as_bytes(), [letter, b':', ..] if letter.is_ascii_alphabetic()) + } + + fn root(path: &str) -> &str { + let drive = if Self::has_drive(path) { 2 } else { 0 }; + let rest = path[drive..].trim_start_matches(Self::SEPARATORS); + &path[..path.len() - rest.len()] + } + + fn normalized<'p>(parts: impl IntoIterator) -> Vec<&'p str> { + let mut normalized = Vec::new(); + for part in parts { + match part { + "" | "." => {} + ".." if normalized.last().is_some_and(|last| *last != "..") => { + normalized.pop(); + } + _ => normalized.push(part), + } + } + normalized + } + + fn quoted(dir: &str) -> String { + if dir + .chars() + .all(|c| c.is_ascii_alphanumeric() || "._-/+@%,:=".contains(c)) + { + dir.to_owned() + } else { + format!("'{}'", dir.replace('\'', r"'\''")) + } + } +} diff --git a/crates/trodden-capture/src/claude_code.rs b/crates/trodden-capture/src/claude_code.rs index dced6ea..82ff415 100644 --- a/crates/trodden-capture/src/claude_code.rs +++ b/crates/trodden-capture/src/claude_code.rs @@ -1,52 +1,26 @@ -use std::{collections::HashMap, path::PathBuf}; - -use anyhow::{Result, bail}; -use jiff::Timestamp; -use serde::Deserialize; +use anyhow::Result; use serde_json::{Map, Value}; use trodden_core::{ - HarnessId, SessionId, Trace, - trace::{Event, EventKind, FileChange, ToolAction, ToolArgs, ToolCall, ToolOutcome}, + Trace, + trace::{FileChange, ToolAction, ToolArgs}, }; use trodden_redact::Redactor; use crate::{ - ErrorSignature, - command::Command, - evidence::CheckOutput, - symbols::{Hunk, SymbolFinder}, + builder::{Finish, Prompts, TraceBuilder}, + diff::Diff, }; pub const HARNESS: &str = "claude-code"; -#[derive(Debug, Clone, Deserialize)] -pub struct HookInput { - pub session_id: String, - #[serde(default)] - pub transcript_path: Option, - pub cwd: PathBuf, - pub hook_event_name: String, - #[serde(default)] - pub prompt: Option, - #[serde(default)] - pub tool_name: Option, - #[serde(default)] - pub error: Option, - #[serde(default)] - pub is_interrupt: bool, - #[serde(default)] - pub stop_hook_active: bool, -} - #[derive(Debug)] pub struct Transcript; impl Transcript { - const SUMMARY_CHARS: usize = 200; - - const SUMMARY_SOURCE_BYTES: usize = 64 * 1024; - - const REDACTED: &str = "[REDACTED:"; + const PROMPTS: Prompts = Prompts { + skipped: Self::NON_PROMPT_PREFIXES, + pasted: Some("", @@ -63,10 +37,6 @@ impl Transcript { "Caveat:", ]; - const PASTED: &str = " Result { - let mut builder = TraceBuilder::new(redactor); + let mut reader = Reader::new(redactor); for line in text.lines().filter_map(Line::parse) { - builder.push(line); + reader.push(line); } - builder.finish() + reader.finish() } } @@ -317,30 +287,14 @@ impl Block { } } -struct TraceBuilder<'a> { - redactor: &'a Redactor, - session: Option, - cwd: String, - here: String, - model: Option, - started_at: Option, - now: Timestamp, - events: Vec, - pending: HashMap, +struct Reader<'a> { + builder: TraceBuilder<'a>, } -impl<'a> TraceBuilder<'a> { +impl<'a> Reader<'a> { fn new(redactor: &'a Redactor) -> Self { Self { - redactor, - session: None, - cwd: String::new(), - here: String::new(), - model: None, - started_at: None, - now: Timestamp::UNIX_EPOCH, - events: Vec::new(), - pending: HashMap::new(), + builder: TraceBuilder::new(HARNESS, redactor), } } @@ -348,23 +302,14 @@ impl<'a> TraceBuilder<'a> { if line.is_sidechain { return; } - if self.session.is_none() { - self.session.clone_from(&line.session_id); + if let Some(session) = &line.session_id { + self.builder.session(session); } if let Some(cwd) = &line.cwd { - let cwd = cwd.trim_end_matches(Transcript::SEPARATORS); - if self.cwd.is_empty() { - cwd.clone_into(&mut self.cwd); - } - cwd.clone_into(&mut self.here); + self.builder.directory(cwd); } - if let Some(at) = line - .timestamp - .as_deref() - .and_then(|t| t.parse::().ok()) - { - self.now = at; - self.started_at.get_or_insert(at); + if let Some(at) = &line.timestamp { + self.builder.time(at); } match line.kind.as_str() { @@ -372,14 +317,14 @@ impl<'a> TraceBuilder<'a> { "assistant" => self.push_assistant(line), "attachment" => { if let Some(text) = line.attachment.and_then(Attachment::queued_prompt) { - self.push_prompt(&text); + self.builder.prompt(&text, &Transcript::PROMPTS); } } "system" if line.subtype.as_deref() == Some("compact_boundary") => { let automatic = line .compact_trigger .is_some_and(|trigger| trigger == "auto"); - self.emit(EventKind::Compaction { automatic }); + self.builder.compaction(automatic); } _ => {} } @@ -390,7 +335,7 @@ impl<'a> TraceBuilder<'a> { match message.content { Content::Text(text) => { if !line.is_meta && !line.is_compact_summary { - self.push_prompt(&text); + self.builder.prompt(&text, &Transcript::PROMPTS); } } Content::Blocks(blocks) => { @@ -414,183 +359,71 @@ impl<'a> TraceBuilder<'a> { } } if let Some(text) = prompt.filter(|_| !line.is_meta && !line.is_compact_summary) { - self.push_prompt(&text); + self.builder.prompt(&text, &Transcript::PROMPTS); } } } } - fn push_prompt(&mut self, text: &str) { - let text = text.trim(); - if text.is_empty() - || Transcript::NON_PROMPT_PREFIXES - .iter() - .any(|prefix| text.starts_with(prefix)) - { - return; - } - let headline = Self::headline(text); - if headline.is_empty() { - return; - } - let summary = self.summary(headline); - self.emit(EventKind::Prompt { summary }); - } - - fn headline(text: &str) -> &str { - let mut wrapper: Option<&str> = None; - let mut pasted = None; - for line in text.lines() { - let mut rest = line.trim(); - while !rest.is_empty() { - if let Some(tag) = wrapper { - let Some(close) = rest.find(&format!(" Option<&'static str> { - Transcript::NON_PROMPT_PREFIXES - .iter() - .chain([&Transcript::PASTED]) - .map(|prefix| prefix.trim_end_matches('>')) - .filter(|tag| tag.starts_with('<')) - .find(|tag| line.starts_with(tag)) - } - - fn after_tag(text: &str) -> &str { - text.find('>').map_or("", |end| text[end + 1..].trim()) - } - - fn summary(&self, line: &str) -> String { - let source = if line.len() > Transcript::SUMMARY_SOURCE_BYTES { - line[..line.floor_char_boundary(Transcript::SUMMARY_SOURCE_BYTES)] - .trim_end_matches(|c: char| !c.is_whitespace()) - } else { - line - }; - let redacted = self.redactor.redact(source); - Self::truncate(&redacted, Transcript::SUMMARY_CHARS) - .trim_end() - .to_owned() - } - - fn truncate(text: &str, chars: usize) -> &str { - let Some((cut, _)) = text.char_indices().nth(chars) else { - return text; - }; - let straddling = text - .match_indices(Transcript::REDACTED) - .map(|(start, _)| start) - .take_while(|&start| start < cut) - .last() - .filter(|&start| text[start..].find(']').is_none_or(|end| start + end >= cut)); - &text[..straddling.unwrap_or(cut)] - } - fn push_assistant(&mut self, line: Line) { let Some(message) = line.message else { return }; - if self.model.is_none() { - self.model = message.model.filter(|model| !model.starts_with('<')); + if let Some(model) = &message.model { + self.builder.model(model); } let Content::Blocks(blocks) = message.content else { return; }; for block in blocks { if let Block::ToolUse { id, name, input } = block - && let Some(call) = self.tool_call(&name, &input) + && let Some((action, args)) = self.tool_call(&name, &input) { - let index = self.emit(EventKind::ToolCall(call)); - self.pending.insert(id, index); + self.builder.call(Some(&id), &name, action, args); } } } - fn tool_call(&self, name: &str, input: &Value) -> Option { + fn tool_call(&self, name: &str, input: &Value) -> Option<(ToolAction, ToolArgs)> { if Transcript::BOOKKEEPING_TOOLS.contains(&name) { return None; } let field = |key: &str| input.get(key).and_then(Value::as_str); + let builder = &self.builder; let mut args = ToolArgs::default(); let action = match name { "Bash" => { - let subdirectory = self.subdirectory(); - let base = if subdirectory.is_some() { - &self.here - } else { - &self.cwd - }; - let command = Command::normalize(field("command").unwrap_or_default(), base); - let text = match subdirectory { - Some(dir) if !command.text().is_empty() => { - format!("cd {} && {}", Self::quoted(dir), command.text()) - } - _ => command.text().to_owned(), - }; - args.command = Some(self.redactor.redact(&text).into_owned()); - command.action() + let (shell, action) = builder.shell(field("command").unwrap_or_default()); + args = shell; + action } "Read" | "NotebookRead" => { args.path = field("file_path") .or_else(|| field("notebook_path")) - .map(|p| self.relative(p)); + .map(|p| builder.relative(p)); ToolAction::Read } "Edit" | "MultiEdit" | "Write" | "NotebookEdit" => { args.path = field("file_path") .or_else(|| field("notebook_path")) - .map(|p| self.relative(p)); + .map(|p| builder.relative(p)); ToolAction::Edit } "Glob" | "Grep" => { - args.pattern = field("pattern").map(|p| { - if name == "Glob" && Self::is_absolute(p) { - self.relative(p) - } else { - self.redactor.redact(p).into_owned() - } - }); - args.path = field("path").map(|p| self.relative(p)); + args.pattern = field("pattern").map(|p| builder.search_pattern(p, name == "Glob")); + args.path = field("path").map(|p| builder.relative(p)); ToolAction::Search } "WebFetch" => { - args.url = field("url").map(|u| self.redactor.redact(u).into_owned()); + args.url = field("url").map(|u| builder.redact(u)); ToolAction::Fetch } "WebSearch" => { - args.query = field("query").map(|q| self.redactor.redact(q).into_owned()); + args.query = field("query").map(|q| builder.redact(q)); ToolAction::Fetch } "Agent" | "Task" => ToolAction::Delegate, _ => ToolAction::Other, }; - Some(ToolCall { - tool: name.to_owned(), - action, - args, - outcome: ToolOutcome::Interrupted, - changes: Vec::new(), - duration_ms: None, - error: None, - }) + Some((action, args)) } fn resolve( @@ -600,9 +433,9 @@ impl<'a> TraceBuilder<'a> { is_error: bool, result: Option<&Value>, ) { - let Some(index) = self.pending.remove(tool_use_id) else { + if !self.builder.is_pending(tool_use_id) { return; - }; + } let changes = if is_error { Vec::new() } else { @@ -611,38 +444,21 @@ impl<'a> TraceBuilder<'a> { .into_iter() .collect() }; - let EventKind::ToolCall(call) = &mut self.events[index].kind else { - return; - }; - let interrupted = result .and_then(|r| r.get("interrupted")) .and_then(Value::as_bool) == Some(true); let text = Self::text(content); - let verdict = call - .args - .command - .as_deref() - .and_then(|command| CheckOutput::verdict(command, &text)); - call.outcome = match (interrupted, is_error, verdict) { - (true, _, _) => ToolOutcome::Interrupted, - (false, true, verdict) => match Self::failure(&text) { - ToolOutcome::Failed { .. } if verdict == Some(true) => ToolOutcome::Succeeded, - outcome => outcome, - }, - (false, false, Some(false)) => ToolOutcome::Failed { exit_code: None }, - (false, false, _) => ToolOutcome::Succeeded, + let finish = match (interrupted, is_error) { + (true, _) => Finish::Interrupted, + (false, true) => Self::failure(&text), + (false, false) => Finish::Succeeded, }; - if matches!(call.outcome, ToolOutcome::Failed { .. }) { - call.error = ErrorSignature::of(&text, self.redactor); - } - call.duration_ms = result + let duration_ms = result .and_then(|r| r.get("durationMs")) .and_then(Value::as_u64); - if call.action == ToolAction::Edit { - call.changes = changes; - } + self.builder + .resolve(tool_use_id, finish, &text, duration_ms, changes); } fn text(content: &Value) -> String { @@ -657,15 +473,15 @@ impl<'a> TraceBuilder<'a> { } } - fn failure(text: &str) -> ToolOutcome { + fn failure(text: &str) -> Finish { if text.starts_with("The user doesn't want") || text.contains("user rejected") { - return ToolOutcome::Interrupted; + return Finish::Interrupted; } let exit_code = text .strip_prefix("Exit code ") .and_then(|rest| rest.split_whitespace().next()) .and_then(|code| code.parse().ok()); - ToolOutcome::Failed { exit_code } + Finish::Failed { exit_code } } fn file_change(&self, result: &Value) -> Option { @@ -677,14 +493,11 @@ impl<'a> TraceBuilder<'a> { .cloned() .unwrap_or_default(); - let mut changed = Vec::new(); - let mut starts = Vec::new(); - let (mut added, mut removed) = (0_u32, 0_u32); + let mut diff = Diff::default(); for hunk in &hunks { if let Some(start) = hunk.get("oldStart").and_then(Value::as_u64) { - starts.push(Hunk { - old_start: usize::try_from(start).unwrap_or(usize::MAX), - }); + diff.starts + .push(usize::try_from(start).unwrap_or(usize::MAX)); } for line in hunk .get("lines") @@ -693,183 +506,33 @@ impl<'a> TraceBuilder<'a> { .flatten() .filter_map(Value::as_str) { - if let Some(text) = line.strip_prefix('+') { - added += 1; - changed.push(text); - } else if let Some(text) = line.strip_prefix('-') { - removed += 1; - changed.push(text); - } + diff.line(line); } } let created = result.get("type").and_then(Value::as_str) == Some("create") || (original.is_none() && result.get("content").is_some()); - let content = result.get("content").and_then(Value::as_str); if created && hunks.is_empty() { - let lines: Vec<&str> = content.unwrap_or_default().lines().collect(); - added = u32::try_from(lines.len()).unwrap_or(u32::MAX); - changed = lines; - } - - Some(FileChange { - path: self.relative(path), - created, - symbols: SymbolFinder::find(changed, original, &starts), - lines_added: added, - lines_removed: removed, - }) - } - - fn relative(&self, path: &str) -> String { - let (base, rest) = if Self::is_absolute(path) { - match self.below(path) { - Some(rest) => ("", rest), - None => return self.redactor.redact(path).into_owned(), - } - } else { - (self.subdirectory().unwrap_or_default(), path) - }; - let separators = self.separators(); - let parts = Self::normalized(base.split(separators).chain(rest.split(separators))); - let text = if parts.first() == Some(&"..") && !self.cwd.is_empty() { - let outside = self.outside(&parts); - match self.below(&outside) { - Some(rest) => Self::joined(&Self::normalized(rest.split(separators))), - None => outside, - } - } else { - Self::joined(&parts) - }; - self.redactor.redact(&text).into_owned() - } - - fn joined(parts: &[&str]) -> String { - if parts.is_empty() { - ".".to_owned() - } else { - parts.join("/") - } - } - - fn outside(&self, parts: &[&str]) -> String { - let root = Self::root(&self.cwd); - let separator = if self.cwd.contains('\\') { "\\" } else { "/" }; - let resolved = Self::normalized( - self.cwd[root.len()..] - .split(self.separators()) - .chain(parts.iter().copied()), - ); - let below_root: Vec<&str> = resolved - .into_iter() - .skip_while(|part| *part == "..") - .collect(); - format!("{root}{}", below_root.join(separator)) - } - - fn below<'p>(&self, path: &'p str) -> Option<&'p str> { - if self.cwd.is_empty() { - return None; - } - let head = path.get(..self.cwd.len())?; - let same = if self.is_windows() { - head.chars().zip(self.cwd.chars()).all(|(a, b)| { - a.eq_ignore_ascii_case(&b) - || (Transcript::SEPARATORS.contains(&a) && Transcript::SEPARATORS.contains(&b)) - }) - } else { - head == self.cwd - }; - let rest = &path[self.cwd.len()..]; - (same && (rest.is_empty() || rest.starts_with(self.separators()))) - .then(|| rest.trim_start_matches(self.separators())) - } - - fn subdirectory(&self) -> Option<&str> { - self.below(&self.here).filter(|dir| !dir.is_empty()) - } - - fn is_windows(&self) -> bool { - Self::has_drive(&self.cwd) || self.cwd.starts_with(r"\\") - } - - fn separators(&self) -> &'static [char] { - if self.is_windows() { - Transcript::SEPARATORS - } else { - &Transcript::SEPARATORS[..1] - } - } - - fn is_absolute(path: &str) -> bool { - path.starts_with(['~', '/', '\\']) - || (Self::has_drive(path) && path[2..].starts_with(Transcript::SEPARATORS)) - } - - fn has_drive(path: &str) -> bool { - matches!(path.as_bytes(), [letter, b':', ..] if letter.is_ascii_alphabetic()) - } - - fn root(path: &str) -> &str { - let drive = if Self::has_drive(path) { 2 } else { 0 }; - let rest = path[drive..].trim_start_matches(Transcript::SEPARATORS); - &path[..path.len() - rest.len()] - } - - fn normalized<'p>(parts: impl IntoIterator) -> Vec<&'p str> { - let mut normalized = Vec::new(); - for part in parts { - match part { - "" | "." => {} - ".." if normalized.last().is_some_and(|last| *last != "..") => { - normalized.pop(); - } - _ => normalized.push(part), - } - } - normalized - } - - fn quoted(dir: &str) -> String { - if dir - .chars() - .all(|c| c.is_ascii_alphanumeric() || "._-/+@%,:=".contains(c)) - { - dir.to_owned() - } else { - format!("'{}'", dir.replace('\'', r"'\''")) + diff = Diff::created( + result + .get("content") + .and_then(Value::as_str) + .unwrap_or_default(), + ); } - } - - fn emit(&mut self, kind: EventKind) -> usize { - let seq = u32::try_from(self.events.len()).unwrap_or(u32::MAX); - self.events.push(Event { - seq, - at: self.now, - kind, - }); - self.events.len() - 1 + Some(self.builder.change(path, created, &diff, original)) } fn finish(self) -> Result { - let Some(session) = self.session else { - bail!("no Claude Code session found in transcript"); - }; - Ok(Trace { - session: SessionId::new(session), - harness: HarnessId::new(HARNESS), - model: self.model, - cwd: self.redactor.redact(&self.cwd).into_owned(), - commit: None, - started_at: self.started_at.unwrap_or(self.now), - events: self.events, - }) + self.builder + .finish("no Claude Code session found in transcript") } } #[cfg(test)] mod tests { use serde_json::json; + use trodden_core::trace::{EventKind, ToolCall, ToolOutcome}; use super::*; @@ -951,14 +614,14 @@ mod tests { ]; let redactor = Redactor::with_home("/home/dev"); for secret in &secrets { - for lead in 150..=Transcript::SUMMARY_CHARS { + for lead in 150..=TraceBuilder::SUMMARY_CHARS { let prompt = format!("{} {secret} to deploy staging", "x".repeat(lead)); let redacted = redactor.redact(&prompt); let summary = Session::summary(&prompt); - let markers = summary.matches(Transcript::REDACTED).count(); + let markers = summary.matches(TraceBuilder::REDACTED).count(); - assert!(redacted.contains(Transcript::REDACTED), "{secret}"); - assert!(summary.chars().count() <= Transcript::SUMMARY_CHARS); + assert!(redacted.contains(TraceBuilder::REDACTED), "{secret}"); + assert!(summary.chars().count() <= TraceBuilder::SUMMARY_CHARS); assert!(redacted.starts_with(&summary), "{summary}"); assert_eq!(summary.matches('[').count(), markers, "{summary}"); assert_eq!(summary.matches(']').count(), markers, "{summary}"); @@ -968,7 +631,7 @@ mod tests { #[test] fn huge_first_lines_drop_the_token_cut_by_the_bound() { - let value = "a".repeat(Transcript::SUMMARY_SOURCE_BYTES - 41); + let value = "a".repeat(TraceBuilder::SUMMARY_SOURCE_BYTES - 41); let prompt = format!( "Deploy with TOKEN={value} and use {} for the smoke test", Secret::anthropic_key() @@ -986,8 +649,8 @@ mod tests { Value::String("The user doesn't want to proceed with this tool use.".to_owned()); assert_eq!( - TraceBuilder::failure(&TraceBuilder::text(&content)), - ToolOutcome::Interrupted + Reader::failure(&Reader::text(&content)), + Finish::Interrupted ); } diff --git a/crates/trodden-capture/src/codex.rs b/crates/trodden-capture/src/codex.rs new file mode 100644 index 0000000..9424227 --- /dev/null +++ b/crates/trodden-capture/src/codex.rs @@ -0,0 +1,1361 @@ +use std::{collections::HashMap, path::PathBuf}; + +use anyhow::{Result, bail}; +use serde_json::Value; +use trodden_core::{ + Trace, + trace::{FileChange, ToolAction, ToolArgs}, +}; +use trodden_redact::Redactor; + +use crate::{ + ErrorSignature, + builder::{Finish, Prompts, TraceBuilder}, + command::Command, + diff::Diff, +}; + +pub const HARNESS: &str = "codex"; + +#[derive(Debug)] +pub struct Rollout; + +impl Rollout { + const PROMPTS: Prompts = Prompts { + skipped: &[ + "", + "", + "", + "", + "", + "", + "# AGENTS.md instructions", + ], + pasted: None, + }; + + const SHELL_TOOLS: &[&str] = &["exec_command", "shell_command", "shell", "local_shell"]; + + const BOOKKEEPING_TOOLS: &[&str] = &[ + "update_plan", + "request_user_input", + "request_permissions", + "wait", + "wait_agent", + "send_input", + "resume_agent", + "close_agent", + "list_agents", + "create_goal", + "get_goal", + "update_goal", + "tool_search", + "list_available_plugins_to_install", + "request_plugin_install", + ]; + + pub fn parse(text: &str, redactor: &Redactor) -> Result { + let lines: Vec = text.lines().filter_map(Line::parse).collect(); + if lines + .iter() + .find(|line| line.kind == "session_meta") + .is_some_and(|meta| Meta::is_subagent(&meta.payload)) + { + bail!("Codex subagent rollouts are learned through their parent session"); + } + let lines = Self::effective(lines); + let mut reader = Reader::new(redactor, Self::has_user_messages(&lines)); + for line in lines { + reader.push(line); + } + reader.trace() + } + + pub fn working_directory(text: &str) -> Option { + text.lines() + .filter_map(Line::parse) + .filter(|line| line.kind == "session_meta" || line.kind == "turn_context") + .find_map(|line| line.payload.get("cwd")?.as_str().map(PathBuf::from)) + } + + pub fn is_subagent(first_line: &str) -> bool { + Line::parse(first_line) + .is_some_and(|line| line.kind == "session_meta" && Meta::is_subagent(&line.payload)) + } + + pub fn failed(command: &str, output: &str, redactor: &Redactor) -> bool { + if let Some(code) = Exec::parse(output).and_then(|exec| exec.exit_code) { + return code != 0; + } + Command::normalize(command, "").action() == ToolAction::Run + && ErrorSignature::of(output, redactor).is_some() + } + + fn has_user_messages(lines: &[Line]) -> bool { + lines + .iter() + .any(|line| line.kind == "event_msg" && line.subtype() == Some("user_message")) + } + + // A fork copies its parent's history first: turns older than the fork's own id belong to + // the parent. + fn effective(lines: Vec) -> Vec { + let mut session: Option = None; + let mut forked = false; + let mut copied = false; + let mut kept: Vec = Vec::with_capacity(lines.len()); + let mut turns: Vec = Vec::new(); + for line in lines { + if line.kind == "session_meta" { + let id = line.payload.get("id").and_then(Value::as_str); + match (&session, id) { + (None, Some(id)) => session = Some(id.to_owned()), + (Some(own), Some(id)) if own != id => { + forked = true; + copied = true; + continue; + } + (None, None) => {} + _ => continue, + } + } + if forked + && let Some(own) = &session + && let Some(turn) = line.turn_id() + { + copied = turn < own.as_str(); + } + if copied { + continue; + } + match (line.kind.as_str(), line.subtype()) { + ("event_msg", Some("user_message")) => turns.push(kept.len()), + ("event_msg", Some("thread_rolled_back")) => { + let count = line + .payload + .get("num_turns") + .and_then(Value::as_u64) + .and_then(|count| usize::try_from(count).ok()) + .unwrap_or_default(); + if count > 0 && !turns.is_empty() { + let first = turns.len().saturating_sub(count); + kept.truncate(turns[first]); + turns.truncate(first); + } + continue; + } + _ => {} + } + kept.push(line); + } + kept + } +} + +#[derive(Debug)] +struct Line { + timestamp: Option, + kind: String, + payload: Value, +} + +impl Line { + fn parse(text: &str) -> Option { + let Value::Object(mut fields) = serde_json::from_str(text).ok()? else { + return None; + }; + let Some(Value::String(kind)) = fields.remove("type") else { + return None; + }; + let timestamp = match fields.remove("timestamp") { + Some(Value::String(at)) => Some(at), + _ => None, + }; + Some(Self { + timestamp, + kind, + payload: fields.remove("payload").unwrap_or_default(), + }) + } + + fn subtype(&self) -> Option<&str> { + self.payload.get("type").and_then(Value::as_str) + } + + fn turn_id(&self) -> Option<&str> { + let starts_turn = self.kind == "turn_context" + || (self.kind == "event_msg" && self.subtype() == Some("task_started")); + starts_turn + .then(|| self.payload.get("turn_id").and_then(Value::as_str)) + .flatten() + } + + fn field<'v>(value: &'v Value, key: &str) -> Option<&'v str> { + value.get(key).and_then(Value::as_str) + } +} + +#[derive(Debug)] +struct Meta; + +impl Meta { + fn is_subagent(payload: &Value) -> bool { + let source = payload.get("source"); + let spawned = source.is_some_and(|source| { + source.get("subagent").is_some() || source.get("internal").is_some() + }); + let thread = Line::field(payload, "thread_source") + .is_some_and(|thread| thread == "subagent" || thread == "memory_consolidation"); + spawned || thread + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Exec<'a> { + exit_code: Option, + running: Option<&'a str>, + wall_ms: Option, + output: &'a str, +} + +impl<'a> Exec<'a> { + fn parse(text: &'a str) -> Option { + let mut exec = Self { + exit_code: None, + running: None, + wall_ms: None, + output: "", + }; + let mut header = false; + let mut offset = 0; + for line in text.split_inclusive('\n') { + offset += line.len(); + let line = line.trim_end(); + if line == "Output:" { + exec.output = &text[offset..]; + break; + } + if let Some(code) = line + .strip_prefix("Process exited with code ") + .or_else(|| line.strip_prefix("Exit code: ")) + { + exec.exit_code = code.trim().parse().ok(); + } else if let Some(process) = line.strip_prefix("Process running with session ID ") { + exec.running = Some(process.trim()); + } else if let Some(wall) = line.strip_prefix("Wall time: ") { + exec.wall_ms = Self::milliseconds(wall); + } else if ![ + "Chunk ID: ", + "Original token count: ", + "Total output lines: ", + ] + .iter() + .any(|prefix| line.starts_with(prefix)) + { + break; + } + header = true; + } + header.then_some(exec) + } + + fn milliseconds(wall: &str) -> Option { + let seconds: f64 = wall.trim().strip_suffix("seconds")?.trim().parse().ok()?; + (seconds.is_finite() && seconds >= 0.0).then(|| { + #[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let ms = (seconds * 1000.0).round() as u64; + ms + }) + } + + fn legacy(text: &str) -> Option<(i32, String, Option)> { + let value: Value = serde_json::from_str(text.trim_start()).ok()?; + let metadata = value.get("metadata")?; + let code = i32::try_from(metadata.get("exit_code")?.as_i64()?).ok()?; + let wall = metadata + .get("duration_seconds") + .and_then(Value::as_f64) + .and_then(|seconds| Self::milliseconds(&format!("{seconds} seconds"))); + let output = Line::field(&value, "output").unwrap_or_default().to_owned(); + Some((code, output, wall)) + } + + fn interrupted(text: &str) -> bool { + text.contains("aborted by user") || text.contains("rejected by user") + } +} + +#[derive(Debug)] +struct Running { + call: String, + output: String, + wall_ms: u64, +} + +#[derive(Debug)] +struct PatchEnd { + changes: Vec, + success: bool, + declined: bool, +} + +struct Reader<'a> { + builder: TraceBuilder<'a>, + prompts_from_events: bool, + cwd: String, + running: HashMap, + polls: HashMap, + patched: HashMap, + patches: HashMap>, + turn_prompted: bool, + compacted: bool, + searches: usize, +} + +impl<'a> Reader<'a> { + fn new(redactor: &'a Redactor, prompts_from_events: bool) -> Self { + Self { + builder: TraceBuilder::new(HARNESS, redactor), + prompts_from_events, + cwd: String::new(), + running: HashMap::new(), + polls: HashMap::new(), + patched: HashMap::new(), + patches: HashMap::new(), + turn_prompted: false, + compacted: false, + searches: 0, + } + } + + fn push(&mut self, line: Line) { + if let Some(at) = &line.timestamp { + self.builder.time(at); + } + let payload = &line.payload; + match (line.kind.as_str(), line.subtype()) { + ("session_meta", _) => { + if let Some(id) = Line::field(payload, "id") { + self.builder.session(id); + } + self.directory(Line::field(payload, "cwd")); + } + ("turn_context", _) => { + self.directory(Line::field(payload, "cwd")); + if let Some(model) = Line::field(payload, "model") { + self.builder.model(model); + } + } + ("compacted", _) | ("event_msg", Some("context_compacted")) => self.compaction(), + ("event_msg", Some("task_started")) => { + self.turn_prompted = false; + self.compacted = false; + } + ("event_msg", Some("user_message")) => { + if let Some(text) = Line::field(payload, "message") { + self.prompt(text); + } + } + ("event_msg", Some("patch_apply_end")) => self.patch_end(payload), + ("response_item", Some("message")) if !self.prompts_from_events => { + self.message(payload); + } + ("response_item", Some("function_call")) => self.function_call(payload), + ("response_item", Some("custom_tool_call")) => self.custom_tool_call(payload), + ("response_item", Some("local_shell_call")) => self.local_shell_call(payload), + ("response_item", Some("web_search_call")) => self.web_search(payload), + ("response_item", Some("function_call_output" | "custom_tool_call_output")) => { + if let Some(id) = Line::field(payload, "call_id") { + let text = Self::text(payload.get("output")); + self.output(id, &text); + } + } + _ => {} + } + } + + fn directory(&mut self, cwd: Option<&str>) { + if let Some(cwd) = cwd.filter(|cwd| !cwd.is_empty()) { + self.builder.directory(cwd); + cwd.clone_into(&mut self.cwd); + } + } + + fn prompt(&mut self, text: &str) { + self.builder.prompt(text, &Rollout::PROMPTS); + self.turn_prompted = true; + self.compacted = false; + } + + fn message(&mut self, payload: &Value) { + if Line::field(payload, "role") != Some("user") { + return; + } + let text = Self::text(payload.get("content")); + self.prompt(&text); + } + + fn compaction(&mut self) { + if !self.compacted { + self.builder.compaction(self.turn_prompted); + self.compacted = true; + } + } + + fn call(&mut self, id: &str, tool: &str, action: ToolAction, args: ToolArgs) { + self.builder.call(Some(id), tool, action, args); + self.compacted = false; + } + + fn function_call(&mut self, payload: &Value) { + let (Some(name), Some(id)) = ( + Line::field(payload, "name"), + Line::field(payload, "call_id"), + ) else { + return; + }; + if Rollout::BOOKKEEPING_TOOLS.contains(&name) { + return; + } + let args = match payload.get("arguments") { + Some(Value::String(text)) => serde_json::from_str(text).unwrap_or_default(), + Some(value) => value.clone(), + None => Value::Null, + }; + let workdir = Line::field(&args, "workdir"); + match name { + "exec_command" => { + let command = Line::field(&args, "cmd").unwrap_or_default(); + match Self::heredoc_patch(command) { + Some(patch) => self.patch(id, patch), + None => self.shell(id, name, command, workdir), + } + } + "shell_command" => { + let command = Line::field(&args, "command").unwrap_or_default(); + self.shell(id, name, command, workdir); + } + "shell" | "container.exec" => { + let command = Self::script(args.get("command")); + match Self::heredoc_patch(&command) { + Some(patch) => self.patch(id, patch), + None => self.shell(id, "shell", &command, workdir), + } + } + "write_stdin" => { + let process = match args.get("session_id") { + Some(Value::Number(number)) => number.to_string(), + Some(Value::String(text)) => text.clone(), + _ => return, + }; + self.polls.insert(id.to_owned(), process); + } + "apply_patch" => { + let patch = Line::field(&args, "input") + .or_else(|| Line::field(&args, "patch")) + .unwrap_or_default(); + self.patch(id, patch); + } + "view_image" => { + let args = ToolArgs { + path: Line::field(&args, "path").map(|path| self.builder.relative(path)), + ..ToolArgs::default() + }; + self.call(id, name, ToolAction::Read, args); + } + "spawn_agent" => self.call(id, name, ToolAction::Delegate, ToolArgs::default()), + _ => self.call(id, name, ToolAction::Other, ToolArgs::default()), + } + } + + fn custom_tool_call(&mut self, payload: &Value) { + let (Some(name), Some(id)) = ( + Line::field(payload, "name"), + Line::field(payload, "call_id"), + ) else { + return; + }; + if name == "apply_patch" { + self.patch(id, Line::field(payload, "input").unwrap_or_default()); + } else if !Rollout::BOOKKEEPING_TOOLS.contains(&name) { + self.call(id, name, ToolAction::Other, ToolArgs::default()); + } + } + + fn local_shell_call(&mut self, payload: &Value) { + let Some(id) = Line::field(payload, "call_id").or_else(|| Line::field(payload, "id")) + else { + return; + }; + let action = payload.get("action").unwrap_or(&Value::Null); + let command = Self::script(action.get("command")); + let workdir = Line::field(action, "working_directory"); + self.shell(id, "local_shell", &command, workdir); + } + + fn web_search(&mut self, payload: &Value) { + let action = payload.get("action").unwrap_or(&Value::Null); + let query = Line::field(action, "query") + .or_else(|| Line::field(action, "url")) + .map(|query| self.builder.redact(query)); + self.searches += 1; + let id = format!("web_search_call {}", self.searches); + self.call( + &id, + "web_search", + ToolAction::Fetch, + ToolArgs { + query, + ..ToolArgs::default() + }, + ); + let finish = match Line::field(payload, "status") { + None | Some("completed") => Finish::Succeeded, + Some(_) => Finish::Interrupted, + }; + self.builder.resolve(&id, finish, "", None, Vec::new()); + } + + fn shell(&mut self, id: &str, tool: &str, command: &str, workdir: Option<&str>) { + let workdir = workdir.filter(|dir| !dir.is_empty()).map(|dir| { + if TraceBuilder::is_absolute(dir) || self.cwd.is_empty() { + dir.to_owned() + } else { + format!("{}/{dir}", self.cwd) + } + }); + if let Some(dir) = &workdir { + self.builder.directory(dir); + } + let (args, action) = self.builder.shell(command); + if workdir.is_some() && !self.cwd.is_empty() { + self.builder.directory(&self.cwd); + } + self.call(id, tool, action, args); + } + + fn script(argv: Option<&Value>) -> String { + let argv: Vec<&str> = argv + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .collect(); + match argv.as_slice() { + [shell, flag, script] + if ["bash", "sh", "zsh", "/bin/bash", "/bin/sh", "/bin/zsh"].contains(shell) + && ["-c", "-lc"].contains(flag) => + { + (*script).to_owned() + } + _ => argv.join(" "), + } + } + + fn heredoc_patch(command: &str) -> Option<&str> { + let command = command.trim_start(); + if !command.starts_with("apply_patch") && !command.starts_with("applypatch") { + return None; + } + let start = command.find("*** Begin Patch")?; + let end = command[start..] + .find("*** End Patch") + .map_or(command.len(), |end| start + end + "*** End Patch".len()); + Some(&command[start..end]) + } + + fn patch(&mut self, id: &str, patch: &str) { + let files = PatchFile::parse(patch); + let path = files.first().map(|file| self.builder.relative(&file.path)); + let changes = files + .iter() + .map(|file| { + self.builder + .change(&file.path, file.created, &PatchFile::diff(&file.body), None) + }) + .collect(); + self.patches.insert(id.to_owned(), changes); + self.call( + id, + "apply_patch", + ToolAction::Edit, + ToolArgs { + path, + ..ToolArgs::default() + }, + ); + } + + fn patch_end(&mut self, payload: &Value) { + let Some(id) = Line::field(payload, "call_id") else { + return; + }; + let mut files: Vec<(&String, &Value)> = payload + .get("changes") + .and_then(Value::as_object) + .into_iter() + .flatten() + .collect(); + files.sort_by(|a, b| a.0.cmp(b.0)); + let changes = files + .into_iter() + .filter_map(|(path, change)| { + let (created, diff) = match Line::field(change, "type")? { + "add" => ( + true, + Diff::created(Line::field(change, "content").unwrap_or_default()), + ), + "delete" => { + let removed = + Diff::created(Line::field(change, "content").unwrap_or_default()); + ( + false, + Diff { + removed: removed.added, + changed: removed.changed, + ..Diff::default() + }, + ) + } + "update" => ( + false, + PatchFile::diff(Line::field(change, "unified_diff").unwrap_or_default()), + ), + _ => return None, + }; + let path = Line::field(change, "move_path").unwrap_or(path); + Some(self.builder.change(path, created, &diff, None)) + }) + .collect(); + let status = Line::field(payload, "status"); + self.patched.insert( + id.to_owned(), + PatchEnd { + changes, + success: payload.get("success").and_then(Value::as_bool) != Some(false) + && status != Some("failed"), + declined: status == Some("declined"), + }, + ); + } + + fn output(&mut self, id: &str, text: &str) { + if let Some(process) = self.polls.remove(id) { + self.poll(&process, text); + return; + } + let Some(tool) = self.builder.pending_call(id).map(|call| call.tool.clone()) else { + return; + }; + let exec = Exec::parse(text); + if let Some(exec) = exec + && let Some(process) = exec.running + { + self.running.insert( + process.to_owned(), + Running { + call: id.to_owned(), + output: exec.output.to_owned(), + wall_ms: exec.wall_ms.unwrap_or_default(), + }, + ); + return; + } + let (finish, output, wall_ms) = if Exec::interrupted(text) { + (Finish::Interrupted, text.to_owned(), None) + } else if let Some(exec) = exec + && let Some(code) = exec.exit_code + { + (Self::exit(code), exec.output.to_owned(), exec.wall_ms) + } else if let Some((code, output, wall_ms)) = Exec::legacy(text) { + (Self::exit(code), output, wall_ms) + } else if Rollout::SHELL_TOOLS.contains(&tool.as_str()) || tool == "apply_patch" { + (Finish::Failed { exit_code: None }, text.to_owned(), None) + } else { + (Finish::Succeeded, text.to_owned(), None) + }; + self.finish(id, finish, &output, wall_ms); + } + + fn poll(&mut self, process: &str, text: &str) { + let Some(exec) = Exec::parse(text) else { + return; + }; + let Some(running) = self.running.get_mut(process) else { + return; + }; + running.output.push_str(exec.output); + running.wall_ms += exec.wall_ms.unwrap_or_default(); + let finish = if Exec::interrupted(text) { + Finish::Interrupted + } else if let Some(code) = exec.exit_code { + Self::exit(code) + } else { + return; + }; + if let Some(running) = self.running.remove(process) { + self.finish( + &running.call, + finish, + &running.output, + Some(running.wall_ms), + ); + } + } + + fn finish(&mut self, id: &str, finish: Finish, output: &str, wall_ms: Option) { + let fallback = self.patches.remove(id).unwrap_or_default(); + let (finish, changes) = match self.patched.remove(id) { + Some(end) if end.declined => (Finish::Interrupted, Vec::new()), + Some(end) if !end.success && finish == Finish::Succeeded => { + (Finish::Failed { exit_code: None }, Vec::new()) + } + Some(end) => (finish, end.changes), + None => (finish, fallback), + }; + self.builder.resolve(id, finish, output, wall_ms, changes); + } + + fn exit(code: i32) -> Finish { + if code == 0 { + Finish::Succeeded + } else { + Finish::Failed { + exit_code: Some(code), + } + } + } + + fn text(value: Option<&Value>) -> String { + match value { + Some(Value::String(text)) => text.clone(), + Some(Value::Array(items)) => items + .iter() + .filter_map(|item| Line::field(item, "text")) + .collect::>() + .join("\n"), + _ => String::new(), + } + } + + fn trace(self) -> Result { + self.builder.finish("no Codex session found in rollout") + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct PatchFile { + pub(crate) path: String, + pub(crate) created: bool, + pub(crate) body: String, +} + +impl PatchFile { + pub(crate) fn parse(patch: &str) -> Vec { + let mut files: Vec = Vec::new(); + for line in patch.lines() { + let header = |prefix: &str| line.strip_prefix(prefix).map(str::trim); + if let Some(path) = header("*** Add File: ") { + files.push(Self::new(path, true)); + } else if let Some(path) = + header("*** Update File: ").or_else(|| header("*** Delete File: ")) + { + files.push(Self::new(path, false)); + } else if let Some(path) = header("*** Move to: ") { + if let Some(file) = files.last_mut() { + path.clone_into(&mut file.path); + } + } else if line.starts_with("*** ") { + } else if let Some(file) = files.last_mut() { + file.body.push_str(line); + file.body.push('\n'); + } + } + files + } + + pub(crate) fn diff(text: &str) -> Diff { + let mut diff = Diff::unified(text); + for header in text.lines().filter_map(|line| line.strip_prefix("@@")) { + let context = match header.find("@@") { + Some(end) => &header[end + 2..], + None => header, + }; + if !context.trim().is_empty() { + diff.changed.push(context.trim().to_owned()); + } + } + diff + } + + fn new(path: &str, created: bool) -> Self { + Self { + path: path.to_owned(), + created, + body: String::new(), + } + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use trodden_core::trace::{EventKind, ToolCall, ToolOutcome}; + + use super::*; + + const CWD: &str = "/home/dev/shop"; + const SESSION: &str = "019f0a10-5c2e-7d41-9b8a-3e6f1c2d4a5b"; + + #[derive(Debug, Default)] + struct Session { + lines: Vec, + second: u32, + } + + impl Session { + fn new() -> Self { + let mut session = Self::default(); + session.record( + "session_meta", + json!({"id": SESSION, "timestamp": "2026-06-07T12:18:19.788Z", "cwd": CWD, + "originator": "codex-tui", "cli_version": "0.137.0", "source": "cli", + "thread_source": "user", "model_provider": "openai", + "base_instructions": {"text": "You are Codex, a coding agent based on GPT-5."}, + "git": {"commit_hash": "22f55037", "branch": "main"}}), + ); + session + } + + fn record(&mut self, kind: &str, payload: Value) -> &mut Self { + self.second += 1; + self.lines.push(json!({ + "timestamp": format!("2026-06-07T12:21:{:02}.584Z", self.second % 60), + "type": kind, + "payload": payload, + })); + self + } + + fn turn(&mut self, turn: &str, prompt: &str) -> &mut Self { + self.record("event_msg", json!({"type": "task_started", "turn_id": turn, "model_context_window": 258_400})) + .record("response_item", json!({"type": "message", "role": "developer", + "content": [{"type": "input_text", "text": "\nFilesystem sandboxing defines which files can be read"}]})) + .record("response_item", json!({"type": "message", "role": "user", + "content": [{"type": "input_text", "text": format!("\n {CWD}\n zsh\n")}]})) + .record("turn_context", json!({"turn_id": turn, "cwd": CWD, "current_date": "2026-06-07", + "approval_policy": "on-request", "model": "gpt-5.5", "effort": "medium"})) + .record("response_item", json!({"type": "message", "role": "user", + "content": [{"type": "input_text", "text": prompt}]})) + .record("event_msg", json!({"type": "user_message", "message": prompt, "images": [], "local_images": [], "text_elements": []})) + } + + fn exec(&mut self, call: &str, cmd: &str) -> &mut Self { + let arguments = json!({"cmd": cmd, "workdir": CWD, "yield_time_ms": 10_000, "max_output_tokens": 20_000}); + self.record( + "response_item", + json!({"type": "function_call", "name": "exec_command", + "arguments": arguments.to_string(), "call_id": call}), + ) + } + + fn poll(&mut self, call: &str, process: u32) -> &mut Self { + let arguments = json!({"session_id": process, "chars": "", "yield_time_ms": 1000, "max_output_tokens": 20_000}); + self.record( + "response_item", + json!({"type": "function_call", "name": "write_stdin", + "arguments": arguments.to_string(), "call_id": call}), + ) + } + + fn exited(&mut self, call: &str, code: i32, output: &str) -> &mut Self { + self.output(call, &format!("Chunk ID: ba3f50\nWall time: 0.2500 seconds\nProcess exited with code {code}\nOriginal token count: 54\nOutput:\n{output}")) + } + + fn running(&mut self, call: &str, process: u32) -> &mut Self { + self.output(call, &format!("Chunk ID: 5f91e7\nWall time: 1.0017 seconds\nProcess running with session ID {process}\nOriginal token count: 14\nOutput:\n Blocking waiting for file lock on build directory\n")) + } + + fn output(&mut self, call: &str, output: &str) -> &mut Self { + self.record( + "response_item", + json!({"type": "function_call_output", "call_id": call, "output": output}), + ) + } + + fn patch(&mut self, call: &str, input: &str, changes: Value, exit: i32) -> &mut Self { + self.record("response_item", json!({"type": "custom_tool_call", "status": "completed", + "call_id": call, "name": "apply_patch", "input": input})) + .record("event_msg", json!({"type": "patch_apply_end", "call_id": call, "turn_id": "t", + "stdout": "Success. Updated the following files:\n", "stderr": "", "success": exit == 0, + "changes": changes, "status": if exit == 0 { "completed" } else { "failed" }})) + .record("response_item", json!({"type": "custom_tool_call_output", "call_id": call, + "output": format!("Exit code: {exit}\nWall time: 0.5 seconds\nOutput:\nSuccess. Updated the following files:\n")})) + } + + fn complete(&mut self, turn: &str) -> &mut Self { + self.record( + "event_msg", + json!({"type": "task_complete", "turn_id": turn, "last_agent_message": "Done."}), + ) + } + + fn text(&self) -> String { + self.lines.iter().map(|line| format!("{line}\n")).collect() + } + + fn trace(&self) -> Trace { + Rollout::parse(&self.text(), &Redactor::with_home("/home/dev")).expect("rollout parses") + } + + fn prompts(&self) -> Vec { + self.trace() + .events + .into_iter() + .filter_map(|event| match event.kind { + EventKind::Prompt { summary } => Some(summary), + _ => None, + }) + .collect() + } + + fn calls(&self) -> Vec { + self.trace() + .events + .into_iter() + .filter_map(|event| match event.kind { + EventKind::ToolCall(call) => Some(call), + _ => None, + }) + .collect() + } + } + + #[test] + fn prompts_come_from_user_messages_only() { + let mut session = Session::new(); + session + .turn("019f0a11-0000-7000-8000-000000000001", "Page 2 repeats the last product. Fix it.\nThe test is in test/") + .record("response_item", json!({"type": "message", "role": "developer", + "content": [{"type": "input_text", "text": "run npm test"}]})) + .record("response_item", json!({"type": "message", "role": "user", + "content": [{"type": "input_text", "text": "\nThe user interrupted the previous turn on purpose."}]})) + .record("response_item", json!({"type": "message", "role": "user", + "content": [{"type": "input_text", "text": "Trodden: the procedure recalled for this task"}]})); + + let trace = session.trace(); + + assert_eq!(trace.session.as_str(), SESSION); + assert_eq!(trace.harness.as_str(), "codex"); + assert_eq!(trace.cwd, "~/shop"); + assert_eq!(trace.model.as_deref(), Some("gpt-5.5")); + assert_eq!( + session.prompts(), + ["Page 2 repeats the last product. Fix it."] + ); + } + + #[test] + fn old_rollouts_without_user_message_events_fall_back_to_user_messages() { + let mut session = Session::new(); + session.turn("019f0a11-0000-7000-8000-000000000001", "Fix the paging bug"); + session + .lines + .retain(|line| line["payload"]["type"] != "user_message"); + + assert_eq!(session.prompts(), ["Fix the paging bug"]); + } + + #[test] + fn shell_commands_take_their_exit_code_from_the_output_header() { + let mut session = Session::new(); + session + .turn("019f0a11-0000-7000-8000-000000000001", "Fix the paging bug") + .exec("call_ok", "npm test") + .exec("call_fmt", "cargo fmt --all -- --check") + .exec("call_read", "cat src/paginate.js") + .exited("call_ok", 0, "1 passing\n") + .exited( + "call_fmt", + 1, + "Diff in /home/dev/shop/src/lib.rs at line 3:\n", + ) + .exited("call_read", 0, "export function paginate(items) {}\n"); + + let calls = session.calls(); + + assert_eq!(calls.len(), 3); + assert_eq!(calls[0].tool, "exec_command"); + assert_eq!(calls[0].action, ToolAction::Run); + assert_eq!(calls[0].args.command.as_deref(), Some("npm test")); + assert_eq!(calls[0].outcome, ToolOutcome::Succeeded); + assert_eq!(calls[0].duration_ms, Some(250)); + assert_eq!(calls[1].outcome, ToolOutcome::Failed { exit_code: Some(1) }); + assert_eq!(calls[2].action, ToolAction::Read); + } + + #[test] + fn long_running_commands_finish_through_write_stdin() { + let mut session = Session::new(); + session + .turn("019f0a11-0000-7000-8000-000000000001", "Make clippy pass") + .exec("call_test", "cargo test --workspace") + .exec("call_clippy", "cargo clippy --workspace --all-targets -- -D warnings") + .running("call_test", 90_670) + .running("call_clippy", 70_738) + .poll("call_poll_1", 70_738) + .output("call_poll_1", "Chunk ID: 1c0223\nWall time: 1.0027 seconds\nProcess running with session ID 70738\nOriginal token count: 4\nOutput:\n Checking shop v0.1.0\n") + .poll("call_poll_2", 90_670) + .poll("call_poll_3", 70_738) + .exited("call_poll_2", 0, "test result: ok. 42 passed; 0 failed\n") + .exited("call_poll_3", 101, "error: this `if` statement can be collapsed\n --> src/router.rs:31:13\n"); + + let calls = session.calls(); + + assert_eq!(calls.len(), 2, "polls are not calls of their own"); + assert_eq!(calls[0].outcome, ToolOutcome::Succeeded); + assert_eq!(calls[0].duration_ms, Some(1252)); + assert_eq!( + calls[1].outcome, + ToolOutcome::Failed { + exit_code: Some(101) + } + ); + assert_eq!(calls[1].duration_ms, Some(2255)); + assert!( + calls[1] + .error + .as_deref() + .is_some_and(|error| error.contains("statement can be collapsed")), + "{:?}", + calls[1].error + ); + } + + #[test] + fn commands_still_running_when_the_turn_is_interrupted_stay_interrupted() { + let mut session = Session::new(); + session + .turn("019f0a11-0000-7000-8000-000000000001", "Run the slow tests") + .exec("call_slow", "npm run test:slow") + .running("call_slow", 23_031) + .record("event_msg", json!({"type": "turn_aborted", "turn_id": "019f0a11-0000-7000-8000-000000000001", "reason": "interrupted"})) + .exec("call_rejected", "rm -rf build") + .output("call_rejected", "exec command rejected by user"); + + let calls = session.calls(); + + assert_eq!(calls[0].outcome, ToolOutcome::Interrupted); + assert_eq!(calls[1].outcome, ToolOutcome::Interrupted); + } + + #[test] + fn patches_record_their_changes_with_line_counts() { + let mut session = Session::new(); + session + .turn("019f0a11-0000-7000-8000-000000000001", "Fix the paging bug") + .patch( + "call_patch", + "*** Begin Patch\n*** Update File: src/paginate.js\n@@ export function paginate\n- const end = start + perPage + 1;\n+ const end = start + perPage;\n*** Add File: test/paging.test.js\n+import { paginate } from '../src/paginate.js';\n+test('page 2', () => {});\n*** End Patch", + json!({ + "/home/dev/shop/test/paging.test.js": {"type": "add", "content": "import { paginate } from '../src/paginate.js';\ntest('page 2', () => {});\n"}, + "/home/dev/shop/src/paginate.js": {"type": "update", "unified_diff": "@@ -3,3 +3,3 @@ export function paginate(items, page, perPage) {\n const start = (page - 1) * perPage;\n- const end = start + perPage + 1;\n+ const end = start + perPage;\n return items.slice(start, end);\n", "move_path": null}, + "/home/dev/shop/src/old.js": {"type": "delete", "content": "a\nb\n"} + }), + 0, + ); + + let calls = session.calls(); + + assert_eq!(calls.len(), 1); + let call = &calls[0]; + assert_eq!(call.tool, "apply_patch"); + assert_eq!(call.action, ToolAction::Edit); + assert_eq!(call.args.path.as_deref(), Some("src/paginate.js")); + assert_eq!(call.outcome, ToolOutcome::Succeeded); + let changes: Vec<(&str, bool, u32, u32)> = call + .changes + .iter() + .map(|change| { + ( + change.path.as_str(), + change.created, + change.lines_added, + change.lines_removed, + ) + }) + .collect(); + assert_eq!( + changes, + [ + ("src/old.js", false, 0, 2), + ("src/paginate.js", false, 1, 1), + ("test/paging.test.js", true, 2, 0) + ] + ); + assert_eq!(call.changes[1].symbols, ["paginate"]); + } + + #[test] + fn failed_patches_change_nothing() { + let mut session = Session::new(); + session + .turn("019f0a11-0000-7000-8000-000000000001", "Fix the paging bug") + .patch("call_patch", "*** Begin Patch\n*** Update File: src/paginate.js\n@@\n-x\n+y\n*** End Patch", json!({}), 1) + .record("response_item", json!({"type": "custom_tool_call", "status": "completed", "call_id": "call_bad", + "name": "apply_patch", "input": "*** Begin Patch\n*** Update File: src/missing.js\n@@\n-x\n+y\n*** End Patch"})) + .record("response_item", json!({"type": "custom_tool_call_output", "call_id": "call_bad", + "output": "apply_patch verification failed: Failed to find expected lines in src/missing.js"})); + + let calls = session.calls(); + + assert_eq!(calls[0].outcome, ToolOutcome::Failed { exit_code: Some(1) }); + assert!(calls[0].changes.is_empty()); + assert_eq!(calls[1].outcome, ToolOutcome::Failed { exit_code: None }); + assert!(calls[1].changes.is_empty()); + } + + #[test] + fn patches_without_an_apply_event_fall_back_to_the_patch_body() { + let mut session = Session::new(); + session + .turn("019f0a11-0000-7000-8000-000000000001", "Fix the paging bug") + .exec("call_heredoc", "apply_patch <<'EOF'\n*** Begin Patch\n*** Update File: src/paginate.js\n*** Move to: src/paging.js\n@@\n- const end = start + perPage + 1;\n+ const end = start + perPage;\n*** End Patch\nEOF") + .exited("call_heredoc", 0, "Success. Updated the following files:\nM src/paging.js\n"); + + let calls = session.calls(); + + assert_eq!(calls[0].tool, "apply_patch"); + assert_eq!(calls[0].action, ToolAction::Edit); + assert_eq!(calls[0].changes[0].path, "src/paging.js"); + assert_eq!( + ( + calls[0].changes[0].lines_added, + calls[0].changes[0].lines_removed + ), + (1, 1) + ); + } + + #[test] + fn bookkeeping_is_ignored_and_other_tools_are_kept() { + let mut session = Session::new(); + session + .turn("019f0a11-0000-7000-8000-000000000001", "Fix the paging bug") + .record("response_item", json!({"type": "function_call", "name": "update_plan", + "arguments": "{\"plan\":[{\"step\":\"Fix\",\"status\":\"in_progress\"}]}", "call_id": "call_plan"})) + .output("call_plan", "Plan updated") + .record("response_item", json!({"type": "function_call", "name": "view_image", + "arguments": "{\"path\":\"/home/dev/shop/docs/page.png\"}", "call_id": "call_image"})) + .record("response_item", json!({"type": "function_call_output", "call_id": "call_image", + "output": [{"type": "input_image", "image_url": "data:image/png;base64,iVBOR"}]})) + .record("response_item", json!({"type": "web_search_call", "status": "completed", + "action": {"type": "search", "query": "paginate off by one"}})) + .record("response_item", json!({"type": "function_call", "name": "mcp__docs__lookup", + "arguments": "{}", "call_id": "call_mcp"})) + .output("call_mcp", "{\"ok\":true}"); + + let calls = session.calls(); + let summary: Vec<(&str, ToolAction, &ToolOutcome)> = calls + .iter() + .map(|call| (call.tool.as_str(), call.action, &call.outcome)) + .collect(); + + assert_eq!( + summary, + [ + ("view_image", ToolAction::Read, &ToolOutcome::Succeeded), + ("web_search", ToolAction::Fetch, &ToolOutcome::Succeeded), + ( + "mcp__docs__lookup", + ToolAction::Other, + &ToolOutcome::Succeeded + ), + ] + ); + assert_eq!(calls[0].args.path.as_deref(), Some("docs/page.png")); + assert_eq!(calls[1].args.query.as_deref(), Some("paginate off by one")); + } + + #[test] + fn a_new_turn_directory_becomes_a_cd_prefix() { + let mut session = Session::new(); + session.turn("019f0a11-0000-7000-8000-000000000001", "Fix the web tests"); + session + .record("response_item", json!({"type": "function_call", "name": "exec_command", + "arguments": json!({"cmd": "npm test", "workdir": "web"}).to_string(), "call_id": "call_web"})) + .exited("call_web", 0, "ok\n") + .exec("call_root", "npm run lint") + .exited("call_root", 0, "ok\n"); + + let calls = session.calls(); + + assert_eq!(calls[0].args.command.as_deref(), Some("cd web && npm test")); + assert_eq!(calls[1].args.command.as_deref(), Some("npm run lint")); + } + + #[test] + fn rolled_back_turns_are_dropped() { + let mut session = Session::new(); + session + .turn("019f0a11-0000-7000-8000-000000000001", "Fix the paging bug") + .exec("call_kept", "npm test") + .exited("call_kept", 0, "ok\n") + .complete("019f0a11-0000-7000-8000-000000000001") + .turn("019f0a12-0000-7000-8000-000000000002", "Implemnt the plan") + .exec("call_dropped", "npm run build") + .record("event_msg", json!({"type": "turn_aborted", "turn_id": "019f0a12-0000-7000-8000-000000000002", "reason": "interrupted"})) + .record("event_msg", json!({"type": "thread_rolled_back", "num_turns": 1})) + .turn("019f0a13-0000-7000-8000-000000000003", "Implement the plan") + .record("event_msg", json!({"type": "thread_rolled_back", "num_turns": 9})) + .turn("019f0a14-0000-7000-8000-000000000004", "Implement the plan in PLAN.md"); + + assert_eq!(session.prompts(), ["Implement the plan in PLAN.md"]); + assert!(session.calls().is_empty()); + } + + #[test] + fn forked_rollouts_skip_the_copied_parent_history() { + let mut session = Session::default(); + session + .record( + "session_meta", + json!({"id": "019f0b00-0000-7000-8000-000000000000", "cwd": CWD, + "source": "cli", "forked_from_id": SESSION}), + ) + .record( + "session_meta", + json!({"id": SESSION, "cwd": CWD, "source": "cli"}), + ) + .turn("019f0a11-0000-7000-8000-000000000001", "Parent prompt") + .exec("call_parent", "npm test") + .exited("call_parent", 0, "ok\n") + .turn("019f0b01-0000-7000-8000-000000000000", "Fork prompt"); + + let trace = session.trace(); + + assert_eq!( + trace.session.as_str(), + "019f0b00-0000-7000-8000-000000000000" + ); + assert_eq!(session.prompts(), ["Fork prompt"]); + assert!(session.calls().is_empty()); + } + + #[test] + fn compactions_are_counted_once() { + let mut session = Session::new(); + session + .turn("019f0a11-0000-7000-8000-000000000001", "Fix the paging bug") + .record( + "compacted", + json!({"message": "Summary of the work so far", "replacement_history": []}), + ) + .record("event_msg", json!({"type": "context_compacted"})) + .record( + "event_msg", + json!({"type": "task_started", "turn_id": "019f0a12-0000-7000-8000-000000000002"}), + ) + .record("compacted", json!({"message": "Manual summary"})); + + let compactions: Vec = session + .trace() + .events + .into_iter() + .filter_map(|event| match event.kind { + EventKind::Compaction { automatic } => Some(automatic), + _ => None, + }) + .collect(); + + assert_eq!(compactions, [true, false]); + } + + #[test] + fn subagent_rollouts_are_left_to_their_parent() { + let meta = + json!({"timestamp": "2026-06-07T12:21:17.584Z", "type": "session_meta", "payload": { + "id": "019f0c00-0000-7000-8000-000000000000", "cwd": CWD, + "source": {"subagent": {"thread_spawn": {"parent_thread_id": SESSION, "depth": 1}}}, + "thread_source": "subagent"}}) + .to_string(); + let redactor = Redactor::with_home("/home/dev"); + + assert!(Rollout::is_subagent(&meta)); + assert!(!Rollout::is_subagent(&Session::new().text())); + assert!(Rollout::parse(&meta, &redactor).is_err()); + } + + #[test] + fn malformed_lines_are_skipped() { + let mut session = Session::new(); + session.turn("019f0a11-0000-7000-8000-000000000001", "Fix the paging bug"); + let text = format!( + "{}not json\n[1,2]\n{{\"type\":\"response_item\",\"payload\":{{\"type\":\"function_call\"}}}}\n{{\"type\":\"event_msg\",\"payload\":7}}\n{{\"timestamp\":\"x\",\"type\":\"response_item\",\"payload\":{{\"type\":\"function_call_output\",\"call_id\":\"nobody\",\"output\":\"Process exited with code 3\"}}}}\n{{\"type\":\"event_msg\",\"payl", + session.text() + ); + + let trace = + Rollout::parse(&text, &Redactor::with_home("/home/dev")).expect("rollout parses"); + + assert_eq!(trace.events.len(), 1); + assert!(Rollout::parse("garbage\n", &Redactor::with_home("/home/dev")).is_err()); + } + + #[test] + fn working_directory_is_the_session_root() { + assert_eq!( + Rollout::working_directory(&Session::new().text()), + Some(PathBuf::from(CWD)) + ); + } + + #[test] + fn hook_outputs_reveal_failures() { + let redactor = Redactor::with_home("/home/dev"); + + assert!(Rollout::failed( + "cargo test", + "error[E0425]: cannot find value `total` in this scope\n --> src/lib.rs:3:5\n", + &redactor + )); + assert!(Rollout::failed( + "make", + "Exit code: 2\nWall time: 1 seconds\nOutput:\nboom\n", + &redactor + )); + assert!(!Rollout::failed( + "cargo test", + "test result: ok. 3 passed; 0 failed\n", + &redactor + )); + assert!(!Rollout::failed( + "cat src/errors.rs", + "error: unknown flag\n", + &redactor + )); + assert!(!Rollout::failed( + "cargo build", + "Process exited with code 0\nOutput:\nerror: looks bad but passed\n", + &redactor + )); + } + + #[test] + fn exec_headers_parse_and_plain_text_does_not() { + let exec = Exec::parse("Chunk ID: ec963a\nWall time: 0.0000 seconds\nProcess exited with code 101\nOriginal token count: 268\nOutput:\n Checking\n") + .expect("header"); + + assert_eq!(exec.exit_code, Some(101)); + assert_eq!(exec.wall_ms, Some(0)); + assert_eq!(exec.output, " Checking\n"); + assert_eq!(Exec::parse("Plan updated"), None); + assert_eq!( + Exec::legacy( + "{\"output\":\"boom\",\"metadata\":{\"exit_code\":2,\"duration_seconds\":1.5}}" + ), + Some((2, "boom".to_owned(), Some(1500))) + ); + } +} diff --git a/crates/trodden-capture/src/copilot.rs b/crates/trodden-capture/src/copilot.rs new file mode 100644 index 0000000..9342424 --- /dev/null +++ b/crates/trodden-capture/src/copilot.rs @@ -0,0 +1,776 @@ +use std::{collections::HashMap, path::PathBuf}; + +use anyhow::Result; +use serde_json::Value; +use trodden_core::{ + Trace, + trace::{FileChange, ToolAction, ToolArgs}, +}; +use trodden_redact::Redactor; + +use crate::{ + ErrorSignature, + builder::{Finish, Prompts, TraceBuilder}, + codex::PatchFile, + command::Command, + diff::Diff, +}; + +pub const HARNESS: &str = "copilot"; + +#[derive(Debug)] +pub struct Events; + +impl Events { + const PROMPTS: Prompts = Prompts { + skipped: &["", ""], + pasted: None, + }; + + const INJECTED_SOURCES: &[&str] = &[ + "skill", + "agent", + "system", + "schedule", + "command", + "notification", + "hook", + "autopilot", + "background", + ]; + + const SHELL_TOOLS: &[&str] = &["bash", "powershell", "shell", "local_shell"]; + + const BOOKKEEPING_TOOLS: &[&str] = &[ + "update_todo", + "report_intent", + "think", + "ask_user", + "read_bash", + "write_bash", + "stop_bash", + "list_bash", + "read_powershell", + "write_powershell", + "stop_powershell", + "list_powershell", + "exit_plan_mode", + "fetch_copilot_cli_documentation", + "skill", + ]; + + const MAX_SPLIT_LINES: usize = 64; + + pub fn parse(text: &str, redactor: &Redactor) -> Result { + let mut reader = Reader::new(redactor); + for event in Self::events(text) { + reader.push(event); + } + reader + .builder + .finish("no Copilot session found in events.jsonl") + } + + pub fn working_directory(text: &str) -> Option { + Self::events(text) + .into_iter() + .filter(|event| event.kind == "session.start" || event.kind == "session.resume") + .find_map(|event| { + event + .data + .pointer("/context/cwd") + .and_then(Value::as_str) + .map(PathBuf::from) + }) + } + + pub fn failed(command: &str, output: &str, redactor: &Redactor) -> bool { + if let Some(code) = Self::exit_marker(output) { + return code != 0; + } + Command::normalize(command, "").action() == ToolAction::Run + && ErrorSignature::of(output, redactor).is_some() + } + + fn exit_marker(output: &str) -> Option { + output.lines().rev().find_map(|line| { + let line = line.trim(); + let inner = line.strip_prefix('<')?.strip_suffix('>')?; + let (_, code) = inner.rsplit_once("exit code ")?; + code.trim().parse().ok() + }) + } + + fn events(text: &str) -> Vec { + let mut events = Vec::new(); + let mut pending = String::new(); + let mut pending_lines = 0; + for line in text.lines() { + if !pending.is_empty() { + let joined = format!("{pending}\\n{line}"); + if let Some(event) = Event::parse(&joined) { + events.push(event); + pending.clear(); + continue; + } + pending = joined; + pending_lines += 1; + if line.starts_with("{\"") || pending_lines > Self::MAX_SPLIT_LINES { + pending.clear(); + } else { + continue; + } + } + match Event::parse(line) { + Some(event) => events.push(event), + None if line.starts_with('{') => { + line.clone_into(&mut pending); + pending_lines = 0; + } + None => {} + } + } + events + } +} + +#[derive(Debug)] +struct Event { + kind: String, + timestamp: Option, + subagent: bool, + data: Value, +} + +impl Event { + fn parse(text: &str) -> Option { + let Value::Object(mut fields) = serde_json::from_str(text).ok()? else { + return None; + }; + let Some(Value::String(kind)) = fields.remove("type") else { + return None; + }; + if fields.get("ephemeral").and_then(Value::as_bool) == Some(true) { + return None; + } + let data = fields.remove("data").unwrap_or_default(); + let subagent = fields.get("agentId").is_some_and(|agent| !agent.is_null()) + || data + .get("parentToolCallId") + .is_some_and(|parent| !parent.is_null()); + Some(Self { + kind, + timestamp: fields + .remove("timestamp") + .and_then(|at| at.as_str().map(str::to_owned)), + subagent, + data, + }) + } + + fn field<'v>(value: &'v Value, key: &str) -> Option<&'v str> { + value.get(key).and_then(Value::as_str) + } +} + +struct Reader<'a> { + builder: TraceBuilder<'a>, + changes: HashMap>, +} + +impl<'a> Reader<'a> { + fn new(redactor: &'a Redactor) -> Self { + Self { + builder: TraceBuilder::new(HARNESS, redactor), + changes: HashMap::new(), + } + } + + fn push(&mut self, event: Event) { + if event.subagent { + return; + } + if let Some(at) = &event.timestamp { + self.builder.time(at); + } + let data = &event.data; + match event.kind.as_str() { + "session.start" => { + if let Some(id) = Event::field(data, "sessionId") { + self.builder.session(id); + } + self.context(data.get("context")); + if let Some(model) = Event::field(data, "selectedModel") { + self.builder.model(model); + } + } + "session.resume" => self.context(data.get("context")), + "session.context_changed" => self.context(Some(data)), + "user.message" => self.prompt(data), + "assistant.message" => { + if let Some(model) = Event::field(data, "model") { + self.builder.model(model); + } + for request in data + .get("toolRequests") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + self.call( + Event::field(request, "toolCallId"), + Event::field(request, "name"), + request.get("arguments"), + ); + } + } + "tool.execution_start" => self.call( + Event::field(data, "toolCallId"), + Event::field(data, "toolName"), + data.get("arguments"), + ), + "tool.execution_complete" => self.complete(data), + "session.compaction_complete" + if data.get("success").and_then(Value::as_bool) != Some(false) => + { + let manual = Event::field(data, "trigger") == Some("manual"); + self.builder.compaction(!manual); + } + _ => {} + } + } + + fn context(&mut self, context: Option<&Value>) { + if let Some(cwd) = context + .and_then(|context| Event::field(context, "cwd")) + .filter(|cwd| !cwd.is_empty()) + { + self.builder.directory(cwd); + } + } + + fn prompt(&mut self, data: &Value) { + if data.get("isAutopilotContinuation").and_then(Value::as_bool) == Some(true) { + return; + } + let injected = Event::field(data, "source").is_some_and(|source| { + Events::INJECTED_SOURCES + .iter() + .any(|prefix| source.starts_with(prefix)) + }); + if injected { + return; + } + if let Some(text) = Event::field(data, "content") { + self.builder.prompt(text, &Events::PROMPTS); + } + } + + fn call(&mut self, id: Option<&str>, name: Option<&str>, arguments: Option<&Value>) { + let (Some(id), Some(name)) = (id, name) else { + return; + }; + if self.builder.is_pending(id) || Events::BOOKKEEPING_TOOLS.contains(&name) { + return; + } + let args = match arguments { + Some(Value::String(text)) => { + serde_json::from_str(text).unwrap_or_else(|_| Value::String(text.clone())) + } + Some(value) => value.clone(), + None => Value::Null, + }; + let field = |key: &str| Event::field(&args, key); + let builder = &self.builder; + let mut tool_args = ToolArgs::default(); + let mut changes = Vec::new(); + let action = match name { + _ if Events::SHELL_TOOLS.contains(&name) => { + let (shell, action) = builder.shell(field("command").unwrap_or_default()); + tool_args = shell; + action + } + "view" | "read" | "read_file" => { + tool_args.path = field("path").map(|path| builder.relative(path)); + ToolAction::Read + } + "create" | "edit" | "write" | "str_replace" | "insert" | "str_replace_editor" => { + let path = field("path").unwrap_or_default(); + let command = field("command").unwrap_or(name); + if command == "view" { + tool_args.path = Some(builder.relative(path)); + ToolAction::Read + } else { + tool_args.path = Some(builder.relative(path)); + let (created, diff) = match command { + "create" | "write" => { + (true, Diff::created(field("file_text").unwrap_or_default())) + } + "insert" => (false, Diff::created(field("new_str").unwrap_or_default())), + "edit" | "str_replace" => ( + false, + Diff::replaced( + field("old_str").unwrap_or_default(), + field("new_str").unwrap_or_default(), + ), + ), + _ => (false, Diff::default()), + }; + changes.push(builder.change(path, created, &diff, None)); + ToolAction::Edit + } + } + "apply_patch" => { + let patch = field("input") + .or_else(|| field("patch")) + .or_else(|| args.as_str()) + .unwrap_or_default(); + let files = PatchFile::parse(patch); + tool_args.path = files.first().map(|file| builder.relative(&file.path)); + changes = files + .iter() + .map(|file| { + builder.change(&file.path, file.created, &PatchFile::diff(&file.body), None) + }) + .collect(); + ToolAction::Edit + } + "grep" | "rg" | "glob" | "search" => { + tool_args.pattern = field("pattern") + .or_else(|| field("query")) + .map(|pattern| builder.search_pattern(pattern, name == "glob")); + tool_args.path = field("path").map(|path| builder.relative(path)); + ToolAction::Search + } + "web_fetch" | "fetch" => { + tool_args.url = field("url").map(|url| builder.redact(url)); + ToolAction::Fetch + } + "web_search" => { + tool_args.query = field("query").map(|query| builder.redact(query)); + ToolAction::Fetch + } + "task" | "agent" | "delegate" => ToolAction::Delegate, + _ => ToolAction::Other, + }; + if !changes.is_empty() { + self.changes.insert(id.to_owned(), changes); + } + self.builder.call(Some(id), name, action, tool_args); + } + + fn complete(&mut self, data: &Value) { + let Some(id) = Event::field(data, "toolCallId") else { + return; + }; + if !self.builder.is_pending(id) { + return; + } + let result = data.get("result").unwrap_or(&Value::Null); + let error = data.get("error").unwrap_or(&Value::Null); + let output = Event::field(result, "content") + .or_else(|| Event::field(error, "message")) + .unwrap_or_default() + .to_owned(); + let exit_code = Self::exit_code(data, &output); + let succeeded = data.get("success").and_then(Value::as_bool) != Some(false); + let finish = if !succeeded && Self::declined(error) { + Finish::Interrupted + } else if !succeeded || exit_code.is_some_and(|code| code != 0) { + Finish::Failed { exit_code } + } else { + Finish::Succeeded + }; + let mut changes = self.changes.remove(id).unwrap_or_default(); + for edit in data + .get("fileEdits") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let Some(path) = Event::field(edit, "path") else { + continue; + }; + let relative = self.builder.relative(path); + if !changes.iter().any(|change| change.path == relative) { + let created = Event::field(edit, "kind") == Some("create"); + changes.push(self.builder.change(path, created, &Diff::default(), None)); + } + } + self.builder.resolve(id, finish, &output, None, changes); + } + + fn exit_code(data: &Value, output: &str) -> Option { + let structured = data + .pointer("/shellExecution/exitCode") + .and_then(Value::as_i64) + .or_else(|| { + data.pointer("/result/contents") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter(|block| Event::field(block, "type") == Some("shell_exit")) + .find_map(|block| block.get("exitCode").and_then(Value::as_i64)) + }) + .and_then(|code| i32::try_from(code).ok()); + structured.or_else(|| Events::exit_marker(output)) + } + + fn declined(error: &Value) -> bool { + let code = Event::field(error, "code") + .unwrap_or_default() + .to_lowercase(); + let message = Event::field(error, "message") + .unwrap_or_default() + .to_lowercase(); + ["denied", "rejected", "cancel", "abort"] + .iter() + .any(|word| code.contains(word)) + || [ + "rejected", + "denied by the user", + "user denied", + "cancelled", + "canceled", + "aborted", + ] + .iter() + .any(|phrase| message.contains(phrase)) + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use trodden_core::trace::{EventKind, ToolCall, ToolOutcome}; + + use super::*; + + const CWD: &str = "/home/dev/shop"; + const SESSION: &str = "3f2c9a4e-8b1d-4c6e-9a7f-1d2e3f4a5b6c"; + + #[derive(Debug, Default)] + struct Session { + lines: Vec, + } + + impl Session { + fn new() -> Self { + let mut session = Self::default(); + session.event( + "session.start", + json!({"sessionId": SESSION, "version": 1, "producer": "copilot-agent", + "copilotVersion": "1.0.94", "startTime": "2026-10-09T10:00:00.000Z", + "selectedModel": "claude-sonnet-4.5", + "context": {"cwd": CWD, "gitRoot": CWD, "branch": "main"}}), + ); + session + } + + fn event(&mut self, kind: &str, data: Value) -> &mut Self { + let line = json!({ + "id": format!("00000000-0000-4000-8000-{:012}", self.lines.len()), + "timestamp": format!("2026-10-09T10:00:{:02}.000Z", self.lines.len() % 60), + "parentId": null, + "type": kind, + "data": data, + }); + self.lines.push(line.to_string()); + self + } + + fn prompt(&mut self, text: &str) -> &mut Self { + self.event("user.message", json!({"content": text, + "transformedContent": format!("2026-10-09\n{text}"), + "attachments": [], "interactionId": "i1"})) + } + + fn tool(&mut self, id: &str, name: &str, arguments: Value) -> &mut Self { + self.event("assistant.message", json!({"messageId": "m1", "content": "", "model": "claude-sonnet-4.5", + "toolRequests": [{"toolCallId": id, "name": name, "arguments": arguments, "type": "function"}]})) + .event("tool.execution_start", json!({"toolCallId": id, "toolName": name, "arguments": arguments})) + } + + fn done(&mut self, id: &str, data: Value) -> &mut Self { + let mut data = data; + data["toolCallId"] = json!(id); + self.event("tool.execution_complete", data) + } + + fn text(&self) -> String { + self.lines.iter().map(|line| format!("{line}\n")).collect() + } + + fn trace(&self) -> Trace { + Events::parse(&self.text(), &Redactor::with_home("/home/dev")).expect("events parse") + } + + fn prompts(&self) -> Vec { + self.trace() + .events + .into_iter() + .filter_map(|event| match event.kind { + EventKind::Prompt { summary } => Some(summary), + _ => None, + }) + .collect() + } + + fn calls(&self) -> Vec { + self.trace() + .events + .into_iter() + .filter_map(|event| match event.kind { + EventKind::ToolCall(call) => Some(call), + _ => None, + }) + .collect() + } + } + + #[test] + fn typed_prompts_are_kept_and_injected_ones_skipped() { + let mut session = Session::new(); + session + .prompt("Page 2 repeats the last product. Fix it.\nDetails follow") + .event("user.message", json!({"content": "Use the pdf skill", "source": "skill-pdf"})) + .event("user.message", json!({"content": "Keep going", "isAutopilotContinuation": true})) + .event("user.message", json!({"content": "Trodden: the procedure recalled for this task is checked with `npm test`"})) + .event("user.message", json!({"content": "ephemeral"})); + let last = session.lines.last_mut().expect("events were added"); + *last = last.replacen("\"parentId\"", "\"ephemeral\":true,\"parentId\"", 1); + + let trace = session.trace(); + + assert_eq!(trace.session.as_str(), SESSION); + assert_eq!(trace.harness.as_str(), "copilot"); + assert_eq!(trace.cwd, "~/shop"); + assert_eq!(trace.model.as_deref(), Some("claude-sonnet-4.5")); + assert_eq!( + session.prompts(), + ["Page 2 repeats the last product. Fix it."] + ); + } + + #[test] + fn shell_calls_take_their_exit_code_from_the_completion() { + let mut session = Session::new(); + session + .prompt("Fix the paging bug") + .tool("call_1", "bash", json!({"command": "npm test", "description": "Run tests"})) + .done("call_1", json!({"success": true, "result": {"content": "Error: Cannot find module 'left-pad'\n"}, + "shellExecution": {"exitCode": 1}})) + .tool("call_2", "bash", json!({"command": "npm run lint"})) + .done("call_2", json!({"success": true, "result": {"content": "lint failed\n"}})) + .tool("call_3", "bash", json!({"command": "npm run build"})) + .done("call_3", json!({"success": true, "result": {"content": "built", + "contents": [{"type": "shell_exit", "shellId": "3", "exitCode": 0}]}})) + .tool("call_4", "bash", json!({"command": "rm -rf dist"})) + .done("call_4", json!({"success": false, "error": {"message": "The user rejected this tool call", "code": "rejected"}})) + .tool("call_5", "bash", json!({"command": "npm start"})); + + let calls = session.calls(); + let outcomes: Vec<&ToolOutcome> = calls.iter().map(|call| &call.outcome).collect(); + + assert_eq!(calls.len(), 5, "requests and starts are one call"); + assert_eq!(calls[0].args.command.as_deref(), Some("npm test")); + assert_eq!(calls[0].action, ToolAction::Run); + assert_eq!( + outcomes, + [ + &ToolOutcome::Failed { exit_code: Some(1) }, + &ToolOutcome::Failed { exit_code: Some(2) }, + &ToolOutcome::Succeeded, + &ToolOutcome::Interrupted, + &ToolOutcome::Interrupted, + ] + ); + assert!( + calls[0] + .error + .as_deref() + .is_some_and(|error| error.contains("cannot find module")) + ); + } + + #[test] + fn edits_record_their_changes() { + let mut session = Session::new(); + session + .prompt("Fix the paging bug") + .tool("call_view", "view", json!({"path": "/home/dev/shop/src/paginate.js"})) + .done("call_view", json!({"success": true, "result": {"content": "1. export function paginate"}})) + .tool("call_edit", "edit", json!({"path": "/home/dev/shop/src/paginate.js", + "old_str": "export function paginate(items) {\n return items.slice(start, end + 1);\n}", + "new_str": "export function paginate(items) {\n return items.slice(start, end);\n}"})) + .done("call_edit", json!({"success": true, "result": {"content": "File updated"}, + "fileEdits": [{"path": "/home/dev/shop/src/paginate.js", "kind": "edit"}]})) + .tool("call_create", "create", json!({"path": "/home/dev/shop/test/paging.test.js", "file_text": "a\nb\nc\n"})) + .done("call_create", json!({"success": true, "result": {"content": "Created"}})) + .tool("call_patch", "apply_patch", json!({"input": "*** Begin Patch\n*** Update File: src/cart.js\n@@ function total\n- sum\n+ sum + tax\n*** End Patch"})) + .done("call_patch", json!({"success": true, "result": {"content": "Done"}, + "fileEdits": [{"path": "/home/dev/shop/src/cart.js", "kind": "edit"}, {"path": "/home/dev/shop/src/tax.js", "kind": "create"}]})) + .tool("call_bad", "edit", json!({"path": "/home/dev/shop/src/x.js", "old_str": "a", "new_str": "b"})) + .done("call_bad", json!({"success": false, "error": {"message": "No match found for old_str"}})); + + let calls = session.calls(); + + assert_eq!(calls[0].action, ToolAction::Read); + assert_eq!(calls[0].args.path.as_deref(), Some("src/paginate.js")); + let change = &calls[1].changes[0]; + assert_eq!( + ( + change.path.as_str(), + change.lines_added, + change.lines_removed + ), + ("src/paginate.js", 1, 1) + ); + assert_eq!(calls[1].changes.len(), 1); + assert!(calls[2].changes[0].created); + assert_eq!(calls[2].changes[0].lines_added, 3); + let patched: Vec<(&str, bool, u32)> = calls[3] + .changes + .iter() + .map(|change| (change.path.as_str(), change.created, change.lines_added)) + .collect(); + assert_eq!( + patched, + [("src/cart.js", false, 1), ("src/tax.js", true, 0)] + ); + assert_eq!(calls[3].changes[0].symbols, ["total"]); + assert_eq!(calls[4].outcome, ToolOutcome::Failed { exit_code: None }); + assert!(calls[4].changes.is_empty()); + } + + #[test] + fn bookkeeping_and_subagents_stay_out_of_the_trace() { + let mut session = Session::new(); + session + .prompt("Fix the paging bug") + .tool("call_todo", "update_todo", json!({"todos": "- [ ] fix"})) + .done("call_todo", json!({"success": true})) + .tool( + "call_task", + "task", + json!({"agent_type": "explore", "prompt": "find paging code"}), + ) + .event( + "tool.execution_start", + json!({"toolCallId": "sub_1", "toolName": "bash", + "arguments": {"command": "rg paginate"}, "parentToolCallId": "call_task"}), + ) + .done( + "call_task", + json!({"success": true, "result": {"content": "src/paginate.js"}}), + ); + let line = json!({"id": "x", "timestamp": "2026-10-09T10:00:59.000Z", "parentId": null, + "agentId": "explore-1", "type": "user.message", "data": {"content": "find paging code"}}); + session.lines.push(line.to_string()); + + let calls = session.calls(); + + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].action, ToolAction::Delegate); + assert_eq!(session.prompts().len(), 1); + } + + #[test] + fn compactions_are_marked() { + let mut session = Session::new(); + session + .prompt("Fix the paging bug") + .event( + "session.compaction_complete", + json!({"success": true, "trigger": "threshold"}), + ) + .event( + "session.compaction_complete", + json!({"success": true, "trigger": "manual"}), + ) + .event( + "session.compaction_complete", + json!({"success": false, "error": "rate limited"}), + ); + + let compactions: Vec = session + .trace() + .events + .into_iter() + .filter_map(|event| match event.kind { + EventKind::Compaction { automatic } => Some(automatic), + _ => None, + }) + .collect(); + + assert_eq!(compactions, [true, false]); + } + + #[test] + fn split_and_broken_lines_are_tolerated() { + let mut session = Session::new(); + session.prompt("Fix the paging bug"); + let complete = json!({"id": "y", "timestamp": "2026-10-09T10:00:30.000Z", "parentId": null, + "type": "tool.execution_complete", + "data": {"toolCallId": "call_1", "success": true, "result": {"content": "line one\\nline two"}}}) + .to_string() + .replace("line one\\\\nline two", "line one\nline two"); + let text = format!( + "{}{}\n{}\n{}\nnot json\n{{\"id\":\"z\",\"type\":\"user.mess\n", + session.text(), + json!({"id": "x", "timestamp": "2026-10-09T10:00:29.000Z", "parentId": null, "type": "tool.execution_start", + "data": {"toolCallId": "call_1", "toolName": "bash", "arguments": {"command": "npm test"}}}), + complete, + json!({"id": "w", "timestamp": "2026-10-09T10:00:31.000Z", "parentId": null, "type": "user.message", + "data": {"content": "Now the cart"}}), + ); + + let trace = Events::parse(&text, &Redactor::with_home("/home/dev")).expect("events parse"); + let kinds: Vec = trace + .events + .iter() + .map(|event| match &event.kind { + EventKind::Prompt { summary } => summary.clone(), + EventKind::ToolCall(call) => format!("{:?}", call.outcome), + other => format!("{other:?}"), + }) + .collect(); + + assert_eq!(kinds, ["Fix the paging bug", "Succeeded", "Now the cart"]); + assert!(Events::parse("", &Redactor::with_home("/home/dev")).is_err()); + } + + #[test] + fn working_directory_comes_from_the_session_start() { + assert_eq!( + Events::working_directory(&Session::new().text()), + Some(PathBuf::from(CWD)) + ); + } + + #[test] + fn hook_outputs_reveal_failures() { + let redactor = Redactor::with_home("/home/dev"); + + assert!(Events::failed( + "npm test", + "all good\n", + &redactor + )); + assert!(!Events::failed( + "npm test", + "Error: boom\n", + &redactor + )); + assert!(Events::failed( + "npm test", + "Error: Cannot find module 'left-pad'\n", + &redactor + )); + assert!(!Events::failed( + "cat notes.txt", + "Error: Cannot find module 'left-pad'\n", + &redactor + )); + } +} diff --git a/crates/trodden-capture/src/diff.rs b/crates/trodden-capture/src/diff.rs new file mode 100644 index 0000000..4eb57ba --- /dev/null +++ b/crates/trodden-capture/src/diff.rs @@ -0,0 +1,137 @@ +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct Diff { + pub(crate) starts: Vec, + pub(crate) changed: Vec, + pub(crate) added: u32, + pub(crate) removed: u32, +} + +impl Diff { + pub(crate) fn line(&mut self, line: &str) { + if let Some(text) = line.strip_prefix('+') { + self.added = self.added.saturating_add(1); + self.changed.push(text.to_owned()); + } else if let Some(text) = line.strip_prefix('-') { + self.removed = self.removed.saturating_add(1); + self.changed.push(text.to_owned()); + } + } + + pub(crate) fn unified(text: &str) -> Self { + let mut diff = Self::default(); + let mut in_hunk = !text.lines().any(|line| line.starts_with("@@")); + for line in text.lines() { + if let Some(header) = line.strip_prefix("@@") { + in_hunk = true; + if let Some(start) = Self::old_start(header) { + diff.starts.push(start); + } + } else if in_hunk { + diff.line(line); + } + } + diff + } + + fn old_start(header: &str) -> Option { + let old = header.trim_start().strip_prefix('-')?; + let digits: String = old.chars().take_while(char::is_ascii_digit).collect(); + digits.parse().ok() + } + + pub(crate) fn created(content: &str) -> Self { + let changed: Vec = content.lines().map(str::to_owned).collect(); + Self { + starts: Vec::new(), + added: u32::try_from(changed.len()).unwrap_or(u32::MAX), + changed, + removed: 0, + } + } + + pub(crate) fn replaced(old: &str, new: &str) -> Self { + let old: Vec<&str> = old.lines().collect(); + let new: Vec<&str> = new.lines().collect(); + let prefix = old + .iter() + .zip(&new) + .take_while(|(old, new)| old == new) + .count(); + let suffix = old[prefix..] + .iter() + .rev() + .zip(new[prefix..].iter().rev()) + .take_while(|(old, new)| old == new) + .count(); + let removed = &old[prefix..old.len() - suffix]; + let added = &new[prefix..new.len() - suffix]; + Self { + starts: Vec::new(), + changed: removed + .iter() + .chain(added) + .map(|line| (*line).to_owned()) + .collect(), + added: u32::try_from(added.len()).unwrap_or(u32::MAX), + removed: u32::try_from(removed.len()).unwrap_or(u32::MAX), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unified_diffs_skip_file_headers_and_keep_hunk_starts() { + let diff = Diff::unified( + "--- a/src/paginate.js\n+++ b/src/paginate.js\n@@ -3,4 +3,4 @@ export function paginate\n const start = (page - 1) * perPage;\n- const end = start + perPage + 1;\n+ const end = start + perPage;\n return items.slice(start, end);\n@@ -20 +20,2 @@\n+// paging\n", + ); + + assert_eq!(diff.starts, [3, 20]); + assert_eq!(diff.added, 2); + assert_eq!(diff.removed, 1); + assert_eq!( + diff.changed, + [ + " const end = start + perPage + 1;", + " const end = start + perPage;", + "// paging" + ] + ); + } + + #[test] + fn patch_bodies_without_line_numbers_still_count_lines() { + let diff = Diff::unified("@@ fn total\n- sum\n+ sum + tax\n"); + + assert!(diff.starts.is_empty()); + assert_eq!((diff.added, diff.removed), (1, 1)); + } + + #[test] + fn bodies_without_hunk_headers_are_all_hunk_lines() { + let diff = Diff::unified("+fn main() {}\n+\n"); + + assert_eq!((diff.added, diff.removed), (2, 0)); + } + + #[test] + fn replacements_drop_the_context_both_sides_share() { + let diff = Diff::replaced( + "fn total() {\n sum\n}\n", + "fn total() {\n sum + tax\n // rounded\n}\n", + ); + + assert_eq!((diff.added, diff.removed), (2, 1)); + assert_eq!(diff.changed, [" sum", " sum + tax", " // rounded"]); + } + + #[test] + fn created_files_add_every_line() { + let diff = Diff::created("a\nb\nc"); + + assert_eq!((diff.added, diff.removed), (3, 0)); + assert_eq!(diff.changed, ["a", "b", "c"]); + } +} diff --git a/crates/trodden-capture/src/droid.rs b/crates/trodden-capture/src/droid.rs new file mode 100644 index 0000000..ae30951 --- /dev/null +++ b/crates/trodden-capture/src/droid.rs @@ -0,0 +1,727 @@ +use anyhow::Result; +use jiff::Timestamp; +use serde_json::Value; +use trodden_core::{ + Trace, + trace::{FileChange, ToolAction, ToolArgs}, +}; +use trodden_redact::Redactor; + +use crate::{ + builder::{Finish, Prompts, TraceBuilder}, + diff::Diff, +}; + +pub const HARNESS: &str = "droid"; + +#[derive(Debug)] +pub struct Transcript; + +impl Transcript { + const PROMPTS: Prompts = Prompts { + skipped: &[ + "", + "", + "", + ", redactor: &Redactor) -> Result { + let mut reader = Reader { + builder: TraceBuilder::new(HARNESS, redactor), + pending: Vec::new(), + }; + if let Some(model) = model { + reader.builder.model(model); + } + for value in text.lines().filter_map(Self::record) { + reader.push(&value); + } + reader + .builder + .finish("no Factory Droid session found in transcript") + } + + pub fn session(text: &str) -> Option { + text.lines() + .filter_map(Self::record) + .find_map(|record| Self::session_of(&record)) + } + + pub fn working_directory(text: &str) -> Option { + text.lines() + .filter_map(Self::record) + .filter(|record| Self::kind(record) == Some("session_start")) + .find_map(|record| { + Self::string(&record, "cwd") + .or_else(|| Self::string(&record, "lastCwd")) + .filter(|cwd| !cwd.is_empty()) + .map(str::to_owned) + }) + } + + fn record(line: &str) -> Option { + serde_json::from_str::(line) + .ok() + .filter(Value::is_object) + } + + fn kind(record: &Value) -> Option<&str> { + record.get("type").and_then(Value::as_str) + } + + fn string<'v>(value: &'v Value, key: &str) -> Option<&'v str> { + value.get(key).and_then(Value::as_str) + } + + fn session_of(record: &Value) -> Option { + let id = if Self::kind(record) == Some("session_start") { + Self::string(record, "id") + } else { + Self::string(record, "session_id").or_else(|| Self::string(record, "sessionId")) + }; + id.filter(|id| !id.is_empty()).map(str::to_owned) + } + + fn timestamp(record: &Value) -> Option { + match record.get("timestamp")? { + Value::String(text) => text.parse().ok(), + Value::Number(number) => Timestamp::from_millisecond(number.as_i64()?).ok(), + _ => None, + } + } +} + +struct Reader<'a> { + builder: TraceBuilder<'a>, + pending: Vec<(String, Value)>, +} + +impl Reader<'_> { + fn push(&mut self, record: &Value) { + if let Some(session) = Transcript::session_of(record) { + self.builder.session(&session); + } + if let Some(at) = Transcript::timestamp(record) { + self.builder.time_at(at); + } + match Transcript::kind(record) { + Some("session_start") => { + if let Some(cwd) = Transcript::string(record, "cwd") + .or_else(|| Transcript::string(record, "lastCwd")) + { + self.builder.directory(cwd); + } + } + Some("message") => self.push_message(record), + Some("compaction_state") => self.builder.compaction(true), + _ => {} + } + } + + fn push_message(&mut self, record: &Value) { + let (role, content) = match record.get("message") { + Some(message) => ( + Transcript::string(message, "role"), + message.get("content").cloned().unwrap_or_default(), + ), + None => ( + Transcript::string(record, "role"), + record.get("text").cloned().unwrap_or_default(), + ), + }; + if let Some(model) = record + .get("message") + .and_then(|message| Transcript::string(message, "model")) + { + self.builder.model(model); + } + let injected = record + .get("visibility") + .is_some_and(|value| !value.is_null()); + match role { + Some("user") => self.push_user(&content, injected), + Some("assistant") => self.push_assistant(&content), + _ => {} + } + } + + fn push_user(&mut self, content: &Value, injected: bool) { + let mut prompt = None; + match content { + Value::String(text) => prompt = Some(text.as_str()), + Value::Array(blocks) => { + for block in blocks { + match Transcript::string(block, "type") { + Some("tool_result") => self.resolve(block), + Some("text") if prompt.is_none() => { + prompt = Transcript::string(block, "text") + } + _ => {} + } + } + } + _ => {} + } + if let Some(text) = prompt.filter(|_| !injected) { + self.builder.prompt(text, &Transcript::PROMPTS); + } + } + + fn push_assistant(&mut self, content: &Value) { + let Value::Array(blocks) = content else { + return; + }; + for block in blocks { + if Transcript::string(block, "type") != Some("tool_use") { + continue; + } + let (Some(id), Some(name)) = ( + Transcript::string(block, "id"), + Transcript::string(block, "name"), + ) else { + continue; + }; + let input = block.get("input").cloned().unwrap_or_default(); + if let Some((action, args)) = self.tool_call(name, &input) { + self.builder.call(Some(id), name, action, args); + self.pending.push((id.to_owned(), input)); + } + } + } + + fn field<'v>(input: &'v Value, keys: &[&str]) -> Option<&'v str> { + keys.iter().find_map(|key| Transcript::string(input, key)) + } + + const PATH: &'static [&'static str] = &["file_path", "path", "filePath", "file"]; + + fn tool_call(&self, name: &str, input: &Value) -> Option<(ToolAction, ToolArgs)> { + if Transcript::BOOKKEEPING_TOOLS.contains(&name) { + return None; + } + let builder = &self.builder; + let mut args = ToolArgs::default(); + let action = match name { + "Execute" => { + let (shell, action) = + builder.shell(Self::field(input, &["command", "cmd"]).unwrap_or_default()); + args = shell; + action + } + "Read" => { + args.path = Self::field(input, Self::PATH).map(|p| builder.relative(p)); + ToolAction::Read + } + "LS" => { + args.path = Self::field(input, &["directory_path", "path", "directory"]) + .map(|p| builder.relative(p)); + ToolAction::Search + } + "Edit" | "MultiEdit" | "Create" | "Write" => { + args.path = Self::field(input, Self::PATH).map(|p| builder.relative(p)); + ToolAction::Edit + } + "ApplyPatch" => { + args.path = Patch::files(Self::patch(input)) + .first() + .map(|(path, _, _)| builder.relative(path)); + ToolAction::Edit + } + "Grep" | "Glob" => { + args.pattern = Self::field(input, &["pattern", "patterns", "query"]) + .map(|p| builder.search_pattern(p, name == "Glob")); + args.path = Self::field(input, &["path", "folder"]).map(|p| builder.relative(p)); + ToolAction::Search + } + "FetchUrl" | "WebFetch" => { + args.url = Self::field(input, &["url"]).map(|u| builder.redact(u)); + ToolAction::Fetch + } + "WebSearch" => { + args.query = Self::field(input, &["query"]).map(|q| builder.redact(q)); + ToolAction::Fetch + } + "Task" => ToolAction::Delegate, + _ => ToolAction::Other, + }; + Some((action, args)) + } + + fn patch(input: &Value) -> &str { + match input { + Value::String(text) => text, + _ => Self::field(input, &["patch", "patchText", "input"]).unwrap_or_default(), + } + } + + fn resolve(&mut self, block: &Value) { + let Some(id) = Transcript::string(block, "tool_use_id") else { + return; + }; + if !self.builder.is_pending(id) { + return; + } + let input = self + .pending + .iter() + .position(|(pending, _)| pending == id) + .map(|index| self.pending.swap_remove(index).1) + .unwrap_or_default(); + let text = Self::text(block.get("content")); + let is_error = block.get("is_error").and_then(Value::as_bool) == Some(true); + let exit_code = Self::exit_code(&text); + let finish = if is_error && Self::declined(&text) { + Finish::Interrupted + } else if is_error || exit_code.is_some_and(|code| code != 0) { + Finish::Failed { exit_code } + } else { + Finish::Succeeded + }; + let changes = match (finish, self.builder.pending_call(id)) { + (Finish::Succeeded, Some(call)) if call.action == ToolAction::Edit => { + let tool = call.tool.clone(); + self.changes(&tool, &input) + } + _ => Vec::new(), + }; + self.builder.resolve(id, finish, &text, None, changes); + } + + fn text(content: Option<&Value>) -> String { + match content { + Some(Value::String(text)) => text.clone(), + Some(Value::Array(parts)) => parts + .iter() + .filter_map(|part| Transcript::string(part, "text")) + .collect::>() + .join("\n"), + _ => String::new(), + } + } + + fn declined(text: &str) -> bool { + let lower = text.to_ascii_lowercase(); + [ + "rejected by the user", + "user rejected", + "cancelled by user", + "canceled by user", + "interrupted by user", + "user denied", + ] + .iter() + .any(|marker| lower.contains(marker)) + } + + fn exit_code(text: &str) -> Option { + let lines: Vec<&str> = text + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect(); + [lines.first(), lines.last()] + .into_iter() + .flatten() + .find_map(|line| Self::exit_line(line)) + } + + fn exit_line(line: &str) -> Option { + let lower = line.to_ascii_lowercase(); + let lower = + lower.trim_matches(|c: char| matches!(c, '[' | ']' | '(' | ')' | '.' | '<' | '>')); + for marker in [ + "exit code:", + "exit code", + "exited with code", + "exit status", + "return code:", + ] { + if let Some(at) = lower.find(marker) { + let rest = lower[at + marker.len()..].trim_start_matches([' ', ':', '=']); + let digits: String = rest + .chars() + .take_while(|c| c.is_ascii_digit() || *c == '-') + .collect(); + if let Ok(code) = digits.parse() { + return Some(code); + } + } + } + None + } + + fn changes(&self, tool: &str, input: &Value) -> Vec { + let path = Self::field(input, Self::PATH); + match tool { + "Create" | "Write" => path + .map(|path| { + let content = Self::field(input, &["content", "text"]).unwrap_or_default(); + self.builder + .change(path, true, &Diff::created(content), None) + }) + .into_iter() + .collect(), + "Edit" => path + .map(|path| { + let old = Self::field(input, &["old_str", "old_string", "oldString"]) + .unwrap_or_default(); + let new = Self::field(input, &["new_str", "new_string", "newString"]) + .unwrap_or_default(); + self.builder + .change(path, false, &Diff::replaced(old, new), None) + }) + .into_iter() + .collect(), + "MultiEdit" => path + .map(|path| { + let mut diff = Diff::default(); + for edit in input + .get("edits") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let old = Self::field(edit, &["old_str", "old_string", "oldString"]) + .unwrap_or_default(); + let new = Self::field(edit, &["new_str", "new_string", "newString"]) + .unwrap_or_default(); + let one = Diff::replaced(old, new); + diff.added = diff.added.saturating_add(one.added); + diff.removed = diff.removed.saturating_add(one.removed); + diff.changed.extend(one.changed); + } + self.builder.change(path, false, &diff, None) + }) + .into_iter() + .collect(), + "ApplyPatch" => Patch::files(Self::patch(input)) + .into_iter() + .map(|(path, created, body)| { + self.builder + .change(&path, created, &Diff::unified(&body), None) + }) + .collect(), + _ => Vec::new(), + } + } +} + +#[derive(Debug)] +struct Patch; + +impl Patch { + fn files(text: &str) -> Vec<(String, bool, String)> { + let mut files: Vec<(String, bool, String)> = Vec::new(); + for line in text.lines() { + let header = [ + ("*** Add File:", true), + ("*** Update File:", false), + ("*** Delete File:", false), + ] + .into_iter() + .find_map(|(prefix, created)| { + line.strip_prefix(prefix).map(|path| (path.trim(), created)) + }); + if let Some((path, created)) = header { + files.push((path.to_owned(), created, String::new())); + } else if line.starts_with("*** ") { + continue; + } else if let Some((_, _, body)) = files.last_mut() { + body.push_str(line); + body.push('\n'); + } + } + files + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use trodden_core::trace::{EventKind, ToolCall, ToolOutcome}; + + use super::*; + + const CWD: &str = "/home/dev/shop"; + + #[derive(Debug, Default)] + struct Session { + lines: Vec, + } + + impl Session { + fn new() -> Self { + let mut session = Self::default(); + session.record( + json!({"type": "session_start", "id": "6f0c2b1e-0d1c-4c1e-9d55-2e7d1f3a9c20", + "title": "Fix paging bug", "cwd": CWD, "version": 2}), + ); + session + } + + fn record(&mut self, value: Value) -> &mut Self { + self.lines.push(value.to_string()); + self + } + + fn message(&mut self, at: Value, role: &str, content: Value) -> &mut Self { + self.record( + json!({"type": "message", "id": format!("m{}", self.lines.len()), "timestamp": at, + "message": {"role": role, "content": content}}), + ) + } + + fn prompt(&mut self, text: &str) -> &mut Self { + self.message( + json!("2026-10-09T10:00:00.000Z"), + "user", + json!([{"type": "text", "text": text}]), + ) + } + + fn tool( + &mut self, + id: &str, + name: &str, + input: Value, + result: Value, + is_error: bool, + ) -> &mut Self { + self.message( + json!("2026-10-09T10:00:03.000Z"), + "assistant", + json!([{"type": "tool_use", "id": id, "name": name, "input": input}]), + ); + self.message( + json!(1_791_540_000_000_i64), + "user", + json!([{"type": "tool_result", "tool_use_id": id, "content": result, "is_error": is_error}]), + ) + } + + fn text(&self) -> String { + self.lines.iter().map(|line| format!("{line}\n")).collect() + } + + fn trace(&self) -> Trace { + Transcript::parse( + &self.text(), + Some("claude-opus-4-5"), + &Redactor::with_home("/home/dev"), + ) + .expect("valid transcript") + } + + fn calls(&self) -> Vec { + self.trace() + .events + .into_iter() + .filter_map(|event| match event.kind { + EventKind::ToolCall(call) => Some(call), + _ => None, + }) + .collect() + } + + fn prompts(&self) -> Vec { + self.trace() + .events + .into_iter() + .filter_map(|event| match event.kind { + EventKind::Prompt { summary } => Some(summary), + _ => None, + }) + .collect() + } + } + + #[test] + fn a_session_reads_as_a_trace() { + let mut session = Session::new(); + session + .prompt("Fix the paging bug in src/paginate.js\nPage 2 repeats an item") + .tool("t1", "Read", json!({"file_path": format!("{CWD}/src/paginate.js")}), json!("..."), false) + .tool( + "t2", + "Edit", + json!({"file_path": format!("{CWD}/src/paginate.js"), + "old_str": "function paginate(items) {\n return items.slice(start, end + 1);\n}", + "new_str": "function paginate(items) {\n return items.slice(start, end);\n}"}), + json!("File edited"), + false, + ) + .tool("t3", "Execute", json!({"command": "npm test"}), json!("1 passing"), false) + .record(json!({"type": "compaction_state", "id": "c1", "timestamp": 1_791_540_100_000_i64, "summaryText": "..."})); + + let trace = session.trace(); + let calls = session.calls(); + + assert_eq!( + trace.session.as_str(), + "6f0c2b1e-0d1c-4c1e-9d55-2e7d1f3a9c20" + ); + assert_eq!(trace.harness.as_str(), "droid"); + assert_eq!(trace.cwd, "~/shop"); + assert_eq!(trace.model.as_deref(), Some("claude-opus-4-5")); + assert_eq!(session.prompts(), ["Fix the paging bug in src/paginate.js"]); + assert_eq!(calls[0].action, ToolAction::Read); + assert_eq!(calls[0].args.path.as_deref(), Some("src/paginate.js")); + assert_eq!(calls[1].changes[0].path, "src/paginate.js"); + assert_eq!( + ( + calls[1].changes[0].lines_added, + calls[1].changes[0].lines_removed + ), + (1, 1) + ); + assert_eq!(calls[2].args.command.as_deref(), Some("npm test")); + assert_eq!(calls[2].outcome, ToolOutcome::Succeeded); + assert!(matches!( + trace.events.last().map(|event| &event.kind), + Some(EventKind::Compaction { .. }) + )); + assert_eq!( + Transcript::working_directory(&session.text()).as_deref(), + Some(CWD) + ); + } + + #[test] + fn failed_commands_keep_their_exit_code_and_error() { + let mut session = Session::new(); + session + .prompt("Fix the build") + .tool("t1", "Execute", json!({"command": "npm test"}), + json!("Error: Cannot find module 'left-pad'\nExit code: 1"), true) + .tool("t2", "Execute", json!({"command": "cargo build"}), + json!([{"type": "text", "text": "error[E0425]: cannot find value `x`\n[Process exited with code 101]"}]), false) + .tool("t3", "Execute", json!({"command": "npm run lint"}), json!("lint failed"), true) + .tool("t4", "Execute", json!({"command": "rm -rf build"}), json!("Command rejected by the user"), true); + + let calls = session.calls(); + + assert_eq!(calls[0].outcome, ToolOutcome::Failed { exit_code: Some(1) }); + assert!(calls[0].error.is_some()); + assert_eq!( + calls[1].outcome, + ToolOutcome::Failed { + exit_code: Some(101) + } + ); + assert_eq!(calls[2].outcome, ToolOutcome::Failed { exit_code: None }); + assert_eq!(calls[3].outcome, ToolOutcome::Interrupted); + } + + #[test] + fn exit_codes_mid_output_are_not_the_command_s() { + assert_eq!( + Reader::exit_code("running\ntest exit code 1 handled ... ok\ndone"), + None + ); + assert_eq!(Reader::exit_code("Exit code 2\nboom"), Some(2)); + assert_eq!(Reader::exit_code("boom\nexit status 3"), Some(3)); + } + + #[test] + fn injected_rows_are_not_prompts() { + let mut session = Session::new(); + session + .record(json!({"type": "message", "id": "h1", "visibility": "hidden", "timestamp": "2026-10-09T10:00:00Z", + "message": {"role": "user", "content": [{"type": "text", "text": "SessionStart hook output"}]}})) + .prompt("\nThe user opened README.md\n") + .prompt("Rename the shipping helper") + .prompt("\nsteps\n") + .prompt("Trodden: the procedure recalled for this task is checked with `npm test`") + .record(json!({"type": "message", "id": "f1", "role": "user", "text": "Also update the changelog", + "session_id": "6f0c2b1e-0d1c-4c1e-9d55-2e7d1f3a9c20"})); + + assert_eq!( + session.prompts(), + ["Rename the shipping helper", "Also update the changelog"] + ); + } + + #[test] + fn interrupted_calls_stay_interrupted() { + let mut session = Session::new(); + session.prompt("Run the tests").message( + json!("2026-10-09T10:00:03Z"), + "assistant", + json!([{"type": "tool_use", "id": "t1", "name": "Execute", "input": {"command": "npm test"}}]), + ); + + assert_eq!(session.calls()[0].outcome, ToolOutcome::Interrupted); + } + + #[test] + fn created_files_and_patches_count_their_lines() { + let mut session = Session::new(); + session + .prompt("Add a changelog") + .tool("t1", "Create", json!({"file_path": format!("{CWD}/CHANGELOG.md"), "content": "# Changes\n\n- paging\n"}), + json!("Created"), false) + .tool("t2", "ApplyPatch", + json!({"patch": "*** Begin Patch\n*** Update File: src/cart.js\n@@ function total\n- return sum;\n+ return sum + tax;\n*** Add File: src/tax.js\n+export const RATE = 0.2;\n*** End Patch\n"}), + json!("Applied"), false) + .tool("t3", "Edit", json!({"file_path": format!("{CWD}/src/a.js"), "old_str": "a", "new_str": "b"}), + json!("Error: old_str not found"), true); + + let calls = session.calls(); + + assert!(calls[0].changes[0].created); + assert_eq!(calls[0].changes[0].lines_added, 3); + assert_eq!(calls[1].args.path.as_deref(), Some("src/cart.js")); + assert_eq!(calls[1].changes.len(), 2); + assert_eq!( + ( + calls[1].changes[0].lines_added, + calls[1].changes[0].lines_removed + ), + (1, 1) + ); + assert!(calls[1].changes[1].created); + assert!(calls[2].changes.is_empty()); + } + + #[test] + fn malformed_lines_and_unknown_rows_are_skipped() { + let mut session = Session::new(); + session.prompt("Fix the build"); + session.lines.push("{\"type\":\"message\",\"tru".to_owned()); + session.lines.push("[1, 2]".to_owned()); + session + .record(json!({"type": "todo_state", "id": "x", "todos": {"todos": []}})) + .record(json!({"type": "message", "id": "p", "message": {"verdict": "allow"}})) + .record(json!({"type": "message", "id": "q", "message": {"role": "user", "content": [{"type": "tool_result"}]}})) + .record(json!({"type": "session_end", "durationMs": 1200})); + + assert_eq!(session.prompts(), ["Fix the build"]); + assert!(Transcript::parse("not json\n", None, &Redactor::with_home("/home/dev")).is_err()); + } + + #[test] + fn subagent_messages_without_a_header_still_name_their_session() { + let line = json!({"type": "message", "id": "f1", "role": "user", "text": "Look up the tax rate", + "session_id": "sub-1"}); + + assert_eq!( + Transcript::session(&format!("{line}\n")).as_deref(), + Some("sub-1") + ); + } +} diff --git a/crates/trodden-capture/src/gemini.rs b/crates/trodden-capture/src/gemini.rs new file mode 100644 index 0000000..b48b6a8 --- /dev/null +++ b/crates/trodden-capture/src/gemini.rs @@ -0,0 +1,935 @@ +use std::collections::HashMap; + +use anyhow::{Result, bail}; +use serde_json::Value; +use trodden_core::{ + Trace, + trace::{FileChange, ToolAction, ToolArgs}, +}; +use trodden_redact::Redactor; + +use crate::{ + builder::{Finish, Prompts, TraceBuilder}, + diff::Diff, +}; + +pub const HARNESS: &str = "gemini"; + +#[derive(Debug)] +pub struct Transcript; + +impl Transcript { + const PROMPTS: Prompts = Prompts { + skipped: &[ + "", + "", + "", + "/", + "?", + ], + pasted: None, + }; + + const BOOKKEEPING_TOOLS: &[&str] = &[ + "write_todos", + "ask_user", + "enter_plan_mode", + "exit_plan_mode", + "update_topic", + "complete_task", + "activate_skill", + "save_memory", + "get_internal_docs", + "tracker_create_task", + "tracker_update_task", + "tracker_get_task", + "tracker_list_tasks", + "tracker_add_dependency", + "tracker_visualize", + ]; + + pub fn session(text: &str) -> Option { + Replay::new(text).session + } + + pub fn directories(text: &str) -> Vec { + Replay::new(text).directories + } + + pub fn parse(text: &str, cwd: Option<&str>, redactor: &Redactor) -> Result { + let replay = Replay::new(text); + if replay.subagent { + bail!("this is a Gemini CLI subagent transcript, not a session"); + } + let mut reader = Reader { + builder: TraceBuilder::new(HARNESS, redactor), + root: cwd.unwrap_or_default().to_owned(), + }; + if let Some(session) = &replay.session { + reader.builder.session(session); + } + if let Some(cwd) = cwd { + reader.builder.directory(cwd); + } + if let Some(start) = &replay.start { + reader.builder.time(start); + } + for entry in &replay.order { + match entry { + Entry::Compaction => reader.builder.compaction(true), + Entry::Message(id) => { + if let Some(message) = replay.messages.get(id) { + reader.push(message); + } + } + } + } + reader + .builder + .finish("no Gemini CLI session found in transcript") + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ShellResult { + pub output: String, + pub exit_code: Option, + pub failed: bool, + pub interrupted: bool, +} + +impl ShellResult { + const TRAILERS: &[&str] = &[ + "Exit Code: ", + "Signal: ", + "Background PIDs: ", + "Process Group PGID: ", + ]; + + pub fn parse(text: &str) -> Self { + let text = text + .split_once("\n\n") + .map_or(text, |(before, _)| before); + let text = text.trim(); + let text = text + .strip_prefix("") + .map_or(text, |rest| { + rest.rsplit_once("") + .map_or(rest, |(inside, _)| inside) + }) + .trim(); + if text.starts_with("Command was cancelled") + || text.starts_with("Command was automatically cancelled") + { + return Self { + output: text.to_owned(), + exit_code: None, + failed: false, + interrupted: true, + }; + } + let mut lines: Vec<&str> = text.lines().collect(); + let mut exit_code = None; + let mut signalled = false; + while let Some(last) = lines.last() { + let Some(trailer) = Self::TRAILERS.iter().find(|t| last.starts_with(**t)) else { + break; + }; + let value = last[trailer.len()..].trim(); + match *trailer { + "Exit Code: " => exit_code = value.parse::().ok(), + "Signal: " => signalled = !value.is_empty(), + _ => {} + } + lines.pop(); + } + let body = lines.join("\n"); + let output = body.strip_prefix("Output: ").unwrap_or(&body); + let output = if output == "(empty)" { "" } else { output }; + Self { + output: output.to_owned(), + failed: exit_code.is_some_and(|code| code != 0) || signalled, + exit_code, + interrupted: false, + } + } +} + +#[derive(Debug)] +enum Entry { + Message(String), + Compaction, +} + +// Checkpoints only add messages, never replace them: compression and masking would erase +// learnable steps. +#[derive(Debug, Default)] +struct Replay { + session: Option, + start: Option, + directories: Vec, + subagent: bool, + order: Vec, + messages: HashMap, +} + +impl Replay { + fn new(text: &str) -> Self { + let mut replay = Self::default(); + let trimmed = text.trim_start(); + if trimmed.starts_with('{') + && let Ok(Value::Object(legacy)) = serde_json::from_str::(trimmed) + && legacy.get("messages").is_some_and(Value::is_array) + { + replay.record(Value::Object(legacy)); + return replay; + } + for line in text.lines() { + if let Ok(record @ Value::Object(_)) = serde_json::from_str::(line) { + replay.record(record); + } + } + replay + } + + fn record(&mut self, record: Value) { + if let Some(target) = record.get("$rewindTo").and_then(Value::as_str) { + self.rewind(target); + } else if let Some(set) = record.get("$set") { + self.metadata(set); + if let Some(messages) = set.get("messages").and_then(Value::as_array) { + self.checkpoint(messages); + } + } else if let Some(id) = record.get("id").and_then(Value::as_str) { + let id = id.to_owned(); + if self.messages.insert(id.clone(), record).is_none() { + self.order.push(Entry::Message(id)); + } + } else if record.get("sessionId").is_some() { + self.metadata(&record); + for message in record + .get("messages") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + if let Some(id) = message.get("id").and_then(Value::as_str) + && self + .messages + .insert(id.to_owned(), message.clone()) + .is_none() + { + self.order.push(Entry::Message(id.to_owned())); + } + } + } + } + + fn metadata(&mut self, fields: &Value) { + if self.session.is_none() + && let Some(session) = fields.get("sessionId").and_then(Value::as_str) + { + self.session = Some(session.to_owned()); + } + if self.start.is_none() + && let Some(start) = fields.get("startTime").and_then(Value::as_str) + { + self.start = Some(start.to_owned()); + } + if let Some(directories) = fields.get("directories").and_then(Value::as_array) { + self.directories = directories + .iter() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect(); + } + if let Some(kind) = fields.get("kind").and_then(Value::as_str) { + self.subagent = kind == "subagent"; + } + } + + fn rewind(&mut self, target: &str) { + let at = self + .order + .iter() + .position(|entry| matches!(entry, Entry::Message(id) if id == target)) + .unwrap_or(0); + for entry in self.order.drain(at..) { + if let Entry::Message(id) = entry { + self.messages.remove(&id); + } + } + } + + fn checkpoint(&mut self, messages: &[Value]) { + let added: Vec<&Value> = messages + .iter() + .filter(|message| { + message + .get("id") + .and_then(Value::as_str) + .is_some_and(|id| !self.messages.contains_key(id)) + }) + .collect(); + let compressed = added.iter().any(|message| { + message.get("type").and_then(Value::as_str) == Some("user") + && Parts::texts(message.get("content")) + .iter() + .any(|text| text.contains("")) + }); + if compressed { + self.order.push(Entry::Compaction); + } + for message in added { + if let Some(id) = message.get("id").and_then(Value::as_str) { + self.messages.insert(id.to_owned(), message.clone()); + self.order.push(Entry::Message(id.to_owned())); + } + } + } +} + +#[derive(Debug)] +struct Parts; + +impl Parts { + fn list(content: Option<&Value>) -> Vec<&Value> { + match content { + Some(Value::Array(parts)) => parts.iter().collect(), + Some(part @ (Value::Object(_) | Value::String(_))) => vec![part], + _ => Vec::new(), + } + } + + fn texts(content: Option<&Value>) -> Vec<&str> { + Self::list(content) + .into_iter() + .filter(|part| part.get("thought").and_then(Value::as_bool) != Some(true)) + .filter_map(|part| match part { + Value::String(text) => Some(text.as_str()), + _ => part.get("text").and_then(Value::as_str), + }) + .collect() + } + + fn has_function_response(content: Option<&Value>) -> bool { + Self::list(content) + .iter() + .any(|part| part.get("functionResponse").is_some()) + } + + fn response(result: Option<&Value>) -> (String, bool) { + let mut text = Vec::new(); + let mut error = false; + for part in Self::list(result) { + let Some(response) = part.get("functionResponse").and_then(|f| f.get("response")) + else { + if let Some(plain) = part.get("text").and_then(Value::as_str) { + text.push(plain.to_owned()); + } + continue; + }; + if let Some(output) = response.get("output") { + text.push(Self::string(output)); + } else if let Some(failure) = response.get("error") { + error = true; + text.push(Self::string(failure)); + } + } + (text.join("\n"), error) + } + + fn string(value: &Value) -> String { + match value { + Value::String(text) => text.clone(), + Value::Object(_) => value + .get("message") + .and_then(Value::as_str) + .map_or_else(|| value.to_string(), str::to_owned), + other => other.to_string(), + } + } +} + +struct Reader<'a> { + builder: TraceBuilder<'a>, + root: String, +} + +impl Reader<'_> { + fn push(&mut self, message: &Value) { + if let Some(at) = message.get("timestamp").and_then(Value::as_str) { + self.builder.time(at); + } + match message.get("type").and_then(Value::as_str) { + Some("user") => self.push_user(message), + Some("gemini") => self.push_gemini(message), + _ => {} + } + } + + fn push_user(&mut self, message: &Value) { + let content = message.get("content"); + if Parts::has_function_response(content) { + return; + } + let shown = message + .get("displayContent") + .filter(|shown| !shown.is_null()); + let texts = Parts::texts(shown.or(content)); + let typed: Vec<&str> = texts + .into_iter() + .filter(|text| !text.trim_start().starts_with("")) + .collect(); + if let Some(first) = typed.first() { + self.builder.prompt(first, &Transcript::PROMPTS); + } + } + + fn push_gemini(&mut self, message: &Value) { + if let Some(model) = message.get("model").and_then(Value::as_str) { + self.builder.model(model); + } + let calls = message + .get("toolCalls") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default(); + for (index, call) in calls.iter().enumerate() { + self.push_call(call, index); + } + } + + fn push_call(&mut self, call: &Value, index: usize) { + let Some(name) = call.get("name").and_then(Value::as_str) else { + return; + }; + if Transcript::BOOKKEEPING_TOOLS.contains(&name) { + return; + } + let args = call.get("args").cloned().unwrap_or_default(); + let Some((action, tool_args)) = self.tool_call(name, &args) else { + return; + }; + let id = call + .get("id") + .and_then(Value::as_str) + .map_or_else(|| format!("{name}-{index}"), str::to_owned); + if self.builder.is_pending(&id) { + return; + } + if let Some(at) = call.get("timestamp").and_then(Value::as_str) { + self.builder.time(at); + } + self.builder.call(Some(&id), name, action, tool_args); + self.resolve(&id, name, action, &args, call); + } + + fn tool_call(&mut self, name: &str, args: &Value) -> Option<(ToolAction, ToolArgs)> { + let field = |key: &str| args.get(key).and_then(Value::as_str); + let mut tool_args = ToolArgs::default(); + let action = match name { + "run_shell_command" => { + let command = field("command").unwrap_or_default(); + let (shell, action) = match field("dir_path").filter(|dir| !dir.is_empty()) { + Some(dir) => self.shell_in(dir, command), + None => self.builder.shell(command), + }; + tool_args = shell; + action + } + "replace" | "edit" | "write_file" => { + tool_args.path = field("file_path").map(|p| self.builder.relative(p)); + ToolAction::Edit + } + "read_file" => { + tool_args.path = field("file_path") + .or_else(|| field("absolute_path")) + .map(|p| self.builder.relative(p)); + ToolAction::Read + } + "list_directory" => { + tool_args.path = field("dir_path") + .or_else(|| field("path")) + .map(|p| self.builder.relative(p)); + ToolAction::Read + } + "read_many_files" => ToolAction::Read, + "grep_search" | "search_file_content" | "glob" => { + tool_args.pattern = + field("pattern").map(|p| self.builder.search_pattern(p, name == "glob")); + tool_args.path = field("dir_path") + .or_else(|| field("path")) + .map(|p| self.builder.relative(p)); + ToolAction::Search + } + "web_fetch" => { + tool_args.url = field("url").map(|u| self.builder.redact(u)); + if tool_args.url.is_none() { + tool_args.query = field("prompt").map(|q| self.builder.redact(q)); + } + ToolAction::Fetch + } + "google_web_search" => { + tool_args.query = field("query").map(|q| self.builder.redact(q)); + ToolAction::Fetch + } + "invoke_agent" | "delegate_to_agent" => ToolAction::Delegate, + _ => ToolAction::Other, + }; + Some((action, tool_args)) + } + + fn shell_in(&mut self, dir: &str, command: &str) -> (ToolArgs, ToolAction) { + if self.root.is_empty() { + return self.builder.shell(command); + } + let here = if TraceBuilder::is_absolute(dir) { + dir.to_owned() + } else { + format!("{}/{}", self.root.trim_end_matches(['/', '\\']), dir) + }; + self.builder.directory(&here); + let shell = self.builder.shell(command); + let root = self.root.clone(); + self.builder.directory(&root); + shell + } + + fn resolve(&mut self, id: &str, name: &str, action: ToolAction, args: &Value, call: &Value) { + let (text, errored) = Parts::response(call.get("result")); + let status = call + .get("status") + .and_then(Value::as_str) + .unwrap_or_default(); + let (finish, output) = match status { + "cancelled" => (Finish::Interrupted, text), + "error" | "success" if errored || status == "error" => { + if Self::denied(&text) { + (Finish::Interrupted, text) + } else { + (Finish::Failed { exit_code: None }, text) + } + } + "success" if name == "run_shell_command" => { + let shell = ShellResult::parse(&text); + let finish = if shell.interrupted { + Finish::Interrupted + } else if shell.failed { + Finish::Failed { + exit_code: shell.exit_code, + } + } else { + Finish::Succeeded + }; + (finish, shell.output) + } + "success" => (Finish::Succeeded, text), + _ => return, + }; + let changes = if action == ToolAction::Edit && finish == Finish::Succeeded { + self.file_change(name, args, call.get("resultDisplay")) + .into_iter() + .collect() + } else { + Vec::new() + }; + self.builder.resolve(id, finish, &output, None, changes); + } + + fn denied(text: &str) -> bool { + text.contains("denied by policy") + || text.starts_with("User denied") + || text.contains("did not allow") + } + + fn file_change(&self, name: &str, args: &Value, display: Option<&Value>) -> Option { + let field = |key: &str| args.get(key).and_then(Value::as_str); + if let Some(display) = display.filter(|display| display.get("fileDiff").is_some()) { + let path = display + .get("filePath") + .and_then(Value::as_str) + .or_else(|| field("file_path"))?; + let original = display.get("originalContent").and_then(Value::as_str); + let created = display.get("isNewFile").and_then(Value::as_bool) == Some(true) + || display.get("originalContent").is_some_and(Value::is_null); + let diff = Diff::unified( + display + .get("fileDiff") + .and_then(Value::as_str) + .unwrap_or_default(), + ); + return Some(self.builder.change(path, created, &diff, original)); + } + let path = field("file_path")?; + let diff = if name == "write_file" { + Diff::created(field("content").unwrap_or_default()) + } else { + Diff::replaced( + field("old_string").unwrap_or_default(), + field("new_string").unwrap_or_default(), + ) + }; + Some(self.builder.change(path, false, &diff, None)) + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use trodden_core::trace::{EventKind, ToolCall, ToolOutcome}; + + use super::*; + + const ROOT: &str = "/home/dev/shop"; + + #[derive(Debug)] + struct Session; + + impl Session { + fn text(lines: &[Value]) -> String { + lines.iter().map(|line| format!("{line}\n")).collect() + } + + fn metadata() -> Value { + json!({"sessionId": "8f0c2a3e-6d1b-4c55-9a7e-1f2b3c4d5e6f", "projectHash": "3b1f", "startTime": "2026-10-09T10:12:20.001Z", "lastUpdated": "2026-10-09T10:12:20.001Z", "kind": "main"}) + } + + fn user(id: &str, text: &str) -> Value { + json!({"id": id, "timestamp": "2026-10-09T10:12:31.500Z", "type": "user", "content": [{"text": text}]}) + } + + fn gemini(id: &str, calls: Value) -> Value { + json!({"id": id, "timestamp": "2026-10-09T10:12:35.010Z", "type": "gemini", "content": "", "model": "gemini-3-pro-preview", "toolCalls": calls}) + } + + fn call(id: &str, name: &str, args: Value, status: &str, response: Value) -> Value { + json!({"id": id, "name": name, "args": args, "status": status, "timestamp": "2026-10-09T10:13:02.120Z", + "result": [{"functionResponse": {"id": id, "name": name, "response": response}}]}) + } + + fn shell(id: &str, command: &str, output: &str) -> Value { + Self::call( + id, + "run_shell_command", + json!({"command": command}), + "success", + json!({"output": format!("\n{output}\n")}), + ) + } + + fn parse(lines: &[Value]) -> Trace { + Transcript::parse( + &Self::text(lines), + Some(ROOT), + &Redactor::with_home("/home/dev"), + ) + .expect("valid transcript") + } + + fn prompts(trace: &Trace) -> Vec { + trace + .events + .iter() + .filter_map(|event| match &event.kind { + EventKind::Prompt { summary } => Some(summary.clone()), + _ => None, + }) + .collect() + } + + fn calls(trace: &Trace) -> Vec { + trace + .events + .iter() + .filter_map(|event| match &event.kind { + EventKind::ToolCall(call) => Some(call.clone()), + _ => None, + }) + .collect() + } + } + + #[test] + fn real_prompts_are_kept_and_injected_text_is_not() { + let trace = Session::parse(&[ + Session::metadata(), + json!({"id": "env", "timestamp": "2026-10-09T10:12:21Z", "type": "user", "content": [{"text": "\nThis is the Gemini CLI."}]}), + json!({"id": "a1", "timestamp": "2026-10-09T10:12:31Z", "type": "user", + "content": [{"text": "fix the paging bug\n@src/paginate.js contents"}, {"text": "<trodden-memory>"}], + "displayContent": [{"text": "fix the paging bug"}]}), + Session::user("a2", "/compress"), + Session::user( + "a3", + "Trodden: the procedure recalled for this task is checked with `npm test`, which has not run since your last change. Run it once before finishing.", + ), + json!({"id": "a4", "timestamp": "2026-10-09T10:14:00Z", "type": "user", "content": [{"functionResponse": {"id": "x", "name": "read_file", "response": {"output": "text"}}}]}), + json!({"id": "a5", "timestamp": "2026-10-09T10:15:00Z", "type": "user", "content": "now add a test for it"}), + ]); + + assert_eq!( + Session::prompts(&trace), + ["fix the paging bug", "now add a test for it"] + ); + assert_eq!( + trace.session.as_str(), + "8f0c2a3e-6d1b-4c55-9a7e-1f2b3c4d5e6f" + ); + assert_eq!(trace.harness.as_str(), HARNESS); + assert_eq!(trace.cwd, "~/shop"); + } + + #[test] + fn shell_outcomes_come_from_the_exit_code_line() { + let trace = Session::parse(&[ + Session::metadata(), + Session::user("a1", "fix the clippy lint"), + Session::gemini( + "b1", + json!([ + Session::shell( + "c1", + "cargo clippy -- -D warnings", + "Output: error: this `if` has identical blocks\nerror: could not compile `shop`\nExit Code: 101\nProcess Group PGID: 51234" + ), + Session::shell( + "c2", + "cargo test", + "Output: test result: ok. 3 passed; 0 failed\nProcess Group PGID: 51240" + ), + Session::shell( + "c3", + "sleep 100", + "Command was cancelled by user before it could complete. There was no output before it was cancelled." + ), + Session::call( + "c4", + "run_shell_command", + json!({"command": "rm -rf /"}), + "error", + json!({"error": "Tool execution denied by policy."}) + ), + Session::call( + "c5", + "run_shell_command", + json!({"command": "make"}), + "cancelled", + json!({"error": "User denied execution."}) + ), + json!({"id": "c6", "name": "run_shell_command", "args": {"command": "npm run dev"}, "status": "executing"}), + ]), + ), + ]); + let calls = Session::calls(&trace); + + assert_eq!(calls.len(), 6); + assert_eq!( + calls[0].outcome, + ToolOutcome::Failed { + exit_code: Some(101) + } + ); + assert!(calls[0].error.is_some()); + assert_eq!( + calls[0].args.command.as_deref(), + Some("cargo clippy -- -D warnings") + ); + assert_eq!(calls[1].outcome, ToolOutcome::Succeeded); + assert_eq!(calls[1].action, ToolAction::Run); + for call in &calls[2..] { + assert_eq!(call.outcome, ToolOutcome::Interrupted, "{call:?}"); + } + assert_eq!(trace.model.as_deref(), Some("gemini-3-pro-preview")); + } + + #[test] + fn commands_in_a_subdirectory_change_into_it_first() { + let trace = Session::parse(&[ + Session::metadata(), + Session::user("a1", "run the web tests"), + Session::gemini( + "b1", + json!([ + Session::call( + "c1", + "run_shell_command", + json!({"command": "npm test", "dir_path": "web"}), + "success", + json!({"output": "Output: ok"}) + ), + Session::shell("c2", "git status", "Output: clean"), + ]), + ), + ]); + let calls = Session::calls(&trace); + + assert_eq!(calls[0].args.command.as_deref(), Some("cd web && npm test")); + assert_eq!(calls[1].args.command.as_deref(), Some("git status")); + } + + #[test] + fn edits_count_lines_from_the_file_diff() { + let diff = "Index: paginate.js\n===================================================================\n--- paginate.js\tCurrent\n+++ paginate.js\tProposed\n@@ -3,3 +3,3 @@\n export function paginate(items, page, perPage) {\n- const end = start + perPage + 1;\n+ const end = start + perPage;\n"; + let trace = Session::parse(&[ + Session::metadata(), + Session::user("a1", "fix paging"), + Session::gemini( + "b1", + json!([ + {"id": "e1", "name": "replace", "status": "success", "timestamp": "2026-10-09T10:13:11Z", + "args": {"file_path": "/home/dev/shop/src/paginate.js", "old_string": "+ 1;", "new_string": ";"}, + "result": [{"functionResponse": {"id": "e1", "name": "replace", "response": {"output": "Successfully modified file"}}}], + "resultDisplay": {"fileDiff": diff, "fileName": "paginate.js", "filePath": "/home/dev/shop/src/paginate.js", "originalContent": "export function paginate(items, page, perPage) {\n", "newContent": "x"}}, + {"id": "e2", "name": "write_file", "status": "success", "timestamp": "2026-10-09T10:13:12Z", + "args": {"file_path": "/home/dev/shop/test/paginate.test.js", "content": "a\nb\n"}, + "result": [{"functionResponse": {"id": "e2", "name": "write_file", "response": {"output": "Created"}}}], + "resultDisplay": {"fileDiff": "@@ -0,0 +1,2 @@\n+a\n+b\n", "filePath": "/home/dev/shop/test/paginate.test.js", "originalContent": null, "newContent": "a\nb\n", "isNewFile": true}}, + {"id": "e3", "name": "replace", "status": "error", "timestamp": "2026-10-09T10:13:13Z", + "args": {"file_path": "/home/dev/shop/src/x.js", "old_string": "a", "new_string": "b"}, + "result": [{"functionResponse": {"id": "e3", "name": "replace", "response": {"error": "Failed to edit, could not find the string to replace."}}}]}, + ]), + ), + ]); + let calls = Session::calls(&trace); + + assert_eq!(calls[0].action, ToolAction::Edit); + assert_eq!(calls[0].args.path.as_deref(), Some("src/paginate.js")); + let change = &calls[0].changes[0]; + assert_eq!( + (change.lines_added, change.lines_removed, change.created), + (1, 1, false) + ); + assert_eq!(change.path, "src/paginate.js"); + let created = &calls[1].changes[0]; + assert_eq!((created.lines_added, created.created), (2, true)); + assert_eq!(calls[2].outcome, ToolOutcome::Failed { exit_code: None }); + assert!(calls[2].changes.is_empty()); + } + + #[test] + fn re_appended_messages_count_once_with_their_last_state() { + let pending = Session::gemini("b1", json!([])); + let done = Session::gemini( + "b1", + json!([Session::shell("c1", "npm test", "Output: ok")]), + ); + let trace = Session::parse(&[ + Session::metadata(), + Session::user("a1", "run the tests"), + json!({"$set": {"lastUpdated": "2026-10-09T10:12:31.501Z"}}), + pending, + done.clone(), + done, + ]); + + assert_eq!(Session::calls(&trace).len(), 1); + assert_eq!(Session::prompts(&trace), ["run the tests"]); + } + + #[test] + fn rewinds_drop_the_rewound_messages() { + let trace = Session::parse(&[ + Session::metadata(), + Session::user("a1", "first try"), + Session::gemini( + "b1", + json!([Session::shell("c1", "npm test", "Output: ok")]), + ), + json!({"$rewindTo": "a1"}), + Session::user("a2", "second try"), + ]); + + assert_eq!(Session::prompts(&trace), ["second try"]); + assert!(Session::calls(&trace).is_empty()); + } + + #[test] + fn compression_checkpoints_mark_a_compaction_and_keep_history() { + let trace = Session::parse(&[ + Session::metadata(), + Session::user("a1", "fix paging"), + Session::gemini( + "b1", + json!([Session::shell("c1", "npm test", "Output: ok")]), + ), + json!({"$set": {"messages": [ + {"id": "s1", "timestamp": "2026-10-09T10:20:00Z", "type": "user", "content": [{"text": "\nfixed paging\n"}]}, + {"id": "s2", "timestamp": "2026-10-09T10:20:00Z", "type": "gemini", "content": "Got it. Thanks for the additional context!"}, + {"id": "b1", "timestamp": "2026-10-09T10:12:35Z", "type": "gemini", "content": "masked"} + ]}}), + Session::user("a2", "now the sorting"), + ]); + let kinds: Vec<&str> = trace + .events + .iter() + .map(|event| match &event.kind { + EventKind::Prompt { .. } => "prompt", + EventKind::ToolCall(_) => "call", + EventKind::Compaction { .. } => "compaction", + _ => "other", + }) + .collect(); + + assert_eq!(kinds, ["prompt", "call", "compaction", "prompt"]); + } + + #[test] + fn legacy_single_object_sessions_parse() { + let legacy = json!({ + "sessionId": "legacy-1", "projectHash": "3b1f", "startTime": "2026-05-01T09:00:00Z", "lastUpdated": "2026-05-01T09:05:00Z", + "messages": [ + Session::user("a1", "fix paging"), + Session::gemini("b1", json!([Session::shell("c1", "npm test", "Output: ok")])) + ] + }); + let text = serde_json::to_string_pretty(&legacy).expect("JSON"); + let trace = Transcript::parse(&text, Some(ROOT), &Redactor::with_home("/home/dev")) + .expect("legacy parses"); + + assert_eq!(trace.session.as_str(), "legacy-1"); + assert_eq!(Session::prompts(&trace), ["fix paging"]); + assert_eq!(Session::calls(&trace).len(), 1); + } + + #[test] + fn malformed_lines_and_unknown_records_are_skipped() { + let mut text = Session::text(&[Session::metadata(), Session::user("a1", "fix paging")]); + text.push_str("not json\n{\"id\": 3}\n[1,2]\n{\"type\":\"info\",\"id\":\"i1\",\"content\":\"\"}\n{\"id\":\"b9\",\"type\":\"gemini\",\"toolCalls\":[{\"name\":5},{\"id\":\"z\"}]}\n"); + let trace = Transcript::parse(&text, Some(ROOT), &Redactor::with_home("/home/dev")) + .expect("still parses"); + + assert_eq!(Session::prompts(&trace), ["fix paging"]); + assert!(Transcript::parse("garbage\n", None, &Redactor::with_home("/home/dev")).is_err()); + } + + #[test] + fn subagent_transcripts_are_refused() { + let text = Session::text(&[ + json!({"sessionId": "sub", "projectHash": "h", "kind": "subagent", "directories": ["/home/dev/shop"]}), + ]); + + assert!(Transcript::parse(&text, Some(ROOT), &Redactor::with_home("/home/dev")).is_err()); + assert_eq!(Transcript::directories(&text), ["/home/dev/shop"]); + assert_eq!(Transcript::session(&text).as_deref(), Some("sub")); + } + + #[test] + fn shell_results_parse_with_and_without_hook_context() { + let failed = ShellResult::parse( + "\nOutput: boom\nError: (none)\nExit Code: 2\nProcess Group PGID: 7\n\n\n<m>", + ); + assert_eq!(failed.output, "boom\nError: (none)"); + assert_eq!(failed.exit_code, Some(2)); + assert!(failed.failed && !failed.interrupted); + + let passed = ShellResult::parse("Output: (empty)\nProcess Group PGID: 7"); + assert_eq!(passed.output, ""); + assert!(!passed.failed); + + let killed = ShellResult::parse("Output: x\nSignal: SIGKILL"); + assert!(killed.failed); + assert_eq!(killed.exit_code, None); + + assert!(ShellResult::parse("Command was automatically cancelled because it exceeded the timeout of 5.0 minutes without output.").interrupted); + } +} diff --git a/crates/trodden-capture/src/journal.rs b/crates/trodden-capture/src/journal.rs new file mode 100644 index 0000000..6623776 --- /dev/null +++ b/crates/trodden-capture/src/journal.rs @@ -0,0 +1,537 @@ +use anyhow::{Context, Result}; +use jiff::Timestamp; +use serde::{Deserialize, Serialize}; +use trodden_core::{ + Trace, + trace::{EventKind, FileChange, ToolAction, ToolArgs, ToolCall, ToolOutcome}, +}; +use trodden_redact::Redactor; + +use crate::{ + ErrorSignature, + builder::{Prompts, TraceBuilder}, + command::Command, + diff::Diff, + evidence::CheckOutput, + symbols::SymbolFinder, +}; + +// Hooks record what some agents never write to disk. Lines are redacted and reduced when +// written; paths become relative only when the whole journal is read. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Observation { + pub at: Timestamp, + pub session: String, + pub cwd: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(flatten)] + pub observed: Observed, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Observed { + Prompt { + text: String, + }, + Call { + tool: String, + action: ToolAction, + #[serde(default, skip_serializing_if = "ToolArgs::is_empty")] + args: ToolArgs, + outcome: ToolOutcome, + #[serde(default, skip_serializing_if = "Option::is_none")] + error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + duration_ms: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + changes: Vec, + }, + Compaction { + automatic: bool, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Ran<'a> { + pub output: &'a str, + pub exit_code: Option, + pub failed: bool, + pub interrupted: bool, + pub duration_ms: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Edit<'a> { + Replaced { old: &'a str, new: &'a str }, + Created { content: &'a str }, + Patched { diff: &'a str }, + Unknown, +} + +#[derive(Debug)] +pub struct Observer<'a> { + pub session: &'a str, + pub cwd: &'a str, + pub at: Timestamp, + pub model: Option<&'a str>, + pub redactor: &'a Redactor, +} + +impl Observer<'_> { + fn observation(&self, observed: Observed) -> Observation { + Observation { + at: self.at, + session: self.session.to_owned(), + cwd: self.cwd.to_owned(), + model: self.model.map(str::to_owned), + observed, + } + } + + fn call(&self, tool: &str, action: ToolAction, args: ToolArgs) -> Observed { + Observed::Call { + tool: tool.to_owned(), + action, + args, + outcome: ToolOutcome::Succeeded, + error: None, + duration_ms: None, + changes: Vec::new(), + } + } + + pub fn prompt(&self, text: &str) -> Observation { + self.observation(Observed::Prompt { + text: TraceBuilder::summarize(text, &Journal::PROMPTS, self.redactor) + .unwrap_or_default(), + }) + } + + pub fn command(&self, tool: &str, command: &str, ran: Ran<'_>) -> Observation { + let verdict = CheckOutput::verdict(command, ran.output); + let outcome = match (ran.interrupted, ran.failed, verdict) { + (true, _, _) => ToolOutcome::Interrupted, + (false, true, Some(true)) | (false, false, None | Some(true)) => ToolOutcome::Succeeded, + (false, true, _) => ToolOutcome::Failed { + exit_code: ran.exit_code, + }, + (false, false, Some(false)) => ToolOutcome::Failed { exit_code: None }, + }; + let error = matches!(outcome, ToolOutcome::Failed { .. }) + .then(|| ErrorSignature::of(ran.output, self.redactor)) + .flatten(); + // Normalized here so heredoc bodies never reach the disk. + let normalized = Command::normalize(command, self.cwd); + let args = ToolArgs { + command: Some(self.redactor.redact_secrets(normalized.text()).into_owned()), + ..ToolArgs::default() + }; + self.observation(Observed::Call { + tool: tool.to_owned(), + action: normalized.action(), + args, + outcome, + error, + duration_ms: ran.duration_ms, + changes: Vec::new(), + }) + } + + pub fn edit(&self, tool: &str, path: &str, edit: Edit<'_>, succeeded: bool) -> Observation { + let (diff, created) = match edit { + Edit::Replaced { old, new } => (Diff::replaced(old, new), false), + Edit::Created { content } => (Diff::created(content), true), + Edit::Patched { diff } => (Diff::unified(diff), false), + Edit::Unknown => (Diff::default(), false), + }; + let path = self.redactor.redact_secrets(path).into_owned(); + let changes = if succeeded { + vec![FileChange { + path: path.clone(), + created, + symbols: SymbolFinder::find(diff.changed.iter().map(String::as_str), None, &[]), + lines_added: diff.added, + lines_removed: diff.removed, + }] + } else { + Vec::new() + }; + self.observation(Observed::Call { + tool: tool.to_owned(), + action: ToolAction::Edit, + args: ToolArgs { + path: Some(path), + ..ToolArgs::default() + }, + outcome: if succeeded { + ToolOutcome::Succeeded + } else { + ToolOutcome::Failed { exit_code: None } + }, + error: None, + duration_ms: None, + changes, + }) + } + + pub fn read(&self, tool: &str, path: &str) -> Observation { + let args = ToolArgs { + path: Some(self.redactor.redact_secrets(path).into_owned()), + ..ToolArgs::default() + }; + self.observation(self.call(tool, ToolAction::Read, args)) + } + + pub fn search(&self, tool: &str, pattern: Option<&str>, path: Option<&str>) -> Observation { + let args = ToolArgs { + pattern: pattern.map(|pattern| self.redactor.redact_secrets(pattern).into_owned()), + path: path.map(|path| self.redactor.redact_secrets(path).into_owned()), + ..ToolArgs::default() + }; + self.observation(self.call(tool, ToolAction::Search, args)) + } + + pub fn fetch(&self, tool: &str, url: Option<&str>, query: Option<&str>) -> Observation { + let args = ToolArgs { + url: url.map(|url| self.redactor.redact_secrets(url).into_owned()), + query: query.map(|query| self.redactor.redact_secrets(query).into_owned()), + ..ToolArgs::default() + }; + self.observation(self.call(tool, ToolAction::Fetch, args)) + } + + pub fn other(&self, tool: &str, action: ToolAction) -> Observation { + self.observation(self.call(tool, action, ToolArgs::default())) + } + + pub fn compaction(&self, automatic: bool) -> Observation { + self.observation(Observed::Compaction { automatic }) + } +} + +impl Observation { + pub fn to_line(&self) -> Result { + let mut line = serde_json::to_string(self).context("encode a journal line")?; + line.push('\n'); + Ok(line) + } +} + +#[derive(Debug)] +pub struct Journal; + +impl Journal { + const PROMPTS: Prompts = Prompts { + skipped: &[], + pasted: None, + }; + + pub fn parse(text: &str, harness: &'static str, redactor: &Redactor) -> Result { + let mut builder = TraceBuilder::new(harness, redactor); + for observation in text + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + { + builder.session(&observation.session); + builder.directory(&observation.cwd); + builder.time_at(observation.at); + if let Some(model) = &observation.model { + builder.model(model); + } + match observation.observed { + Observed::Prompt { text } => builder.prompt(&text, &Self::PROMPTS), + Observed::Compaction { automatic } => builder.compaction(automatic), + Observed::Call { + tool, + action, + args, + outcome, + error, + duration_ms, + changes, + } => { + let call = Self::relative( + &builder, + ToolCall { + tool, + action, + args, + outcome, + changes, + duration_ms, + error, + }, + ); + builder.emit(EventKind::ToolCall(call)); + } + } + } + builder.finish("no session found in journal") + } + + pub fn working_directory(text: &str) -> Option { + text.lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .map(|observation| observation.cwd) + .find(|cwd| !cwd.is_empty()) + } + + fn relative(builder: &TraceBuilder<'_>, mut call: ToolCall) -> ToolCall { + if let Some(command) = &call.args.command { + call.args.command = builder.shell(command).0.command; + } + if let Some(path) = &call.args.path { + call.args.path = Some(builder.relative(path)); + } + if let Some(pattern) = &call.args.pattern { + call.args.pattern = Some(builder.search_pattern(pattern, true)); + } + for change in &mut call.changes { + change.path = builder.relative(&change.path); + } + call + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Debug)] + struct Session { + redactor: Redactor, + lines: String, + } + + impl Session { + fn new() -> Self { + Self { + redactor: Redactor::with_home("/home/dev"), + lines: String::new(), + } + } + + fn observe(&mut self, cwd: &str, at: &str, record: impl Fn(&Observer<'_>) -> Observation) { + let observer = Observer { + session: "conv-1", + cwd, + at: at.parse().expect("valid timestamp"), + model: Some("gpt-5.5"), + redactor: &self.redactor, + }; + self.lines + .push_str(&record(&observer).to_line().expect("line encodes")); + } + + fn trace(&self) -> Trace { + Journal::parse(&self.lines, "cursor", &self.redactor).expect("journal parses") + } + + fn calls(&self) -> Vec { + self.trace() + .events + .into_iter() + .filter_map(|event| match event.kind { + EventKind::ToolCall(call) => Some(call), + _ => None, + }) + .collect() + } + } + + #[test] + fn a_journal_reads_back_as_a_trace() { + let mut session = Session::new(); + session.observe("/home/dev/shop", "2026-10-09T10:00:00Z", |o| { + o.prompt("Page 2 repeats the last product. Fix it.\nMore detail here") + }); + session.observe("/home/dev/shop", "2026-10-09T10:00:05Z", |o| { + o.edit( + "StrReplace", + "/home/dev/shop/src/paginate.js", + Edit::Replaced { + old: "export function paginate(items) {\n return items.slice(start, end + 1);\n}", + new: "export function paginate(items) {\n return items.slice(start, end);\n}", + }, + true, + ) + }); + session.observe("/home/dev/shop", "2026-10-09T10:00:09Z", |o| { + o.command( + "Shell", + "npm test", + Ran { + output: "1 passing", + exit_code: Some(0), + failed: false, + interrupted: false, + duration_ms: Some(900), + }, + ) + }); + + let trace = session.trace(); + + assert_eq!(trace.session.as_str(), "conv-1"); + assert_eq!(trace.harness.as_str(), "cursor"); + assert_eq!(trace.cwd, "~/shop"); + assert_eq!(trace.model.as_deref(), Some("gpt-5.5")); + assert!(matches!( + &trace.events[0].kind, + EventKind::Prompt { summary } if summary == "Page 2 repeats the last product. Fix it." + )); + let calls = session.calls(); + assert_eq!(calls[0].changes[0].path, "src/paginate.js"); + assert_eq!( + ( + calls[0].changes[0].lines_added, + calls[0].changes[0].lines_removed + ), + (1, 1) + ); + assert_eq!(calls[1].args.command.as_deref(), Some("npm test")); + assert_eq!(calls[1].action, ToolAction::Run); + assert_eq!(calls[1].outcome, ToolOutcome::Succeeded); + assert_eq!(calls[1].duration_ms, Some(900)); + } + + #[test] + fn failed_commands_keep_their_exit_code_and_error() { + let mut session = Session::new(); + session.observe("/home/dev/shop", "2026-10-09T10:00:09Z", |o| { + o.command( + "Shell", + "npm test", + Ran { + output: "Error: Cannot find module 'left-pad'\n", + exit_code: Some(1), + failed: true, + interrupted: false, + duration_ms: None, + }, + ) + }); + + let call = session.calls().remove(0); + + assert_eq!(call.outcome, ToolOutcome::Failed { exit_code: Some(1) }); + assert!(call.error.is_some()); + } + + #[test] + fn commands_from_a_subdirectory_replay_from_the_first_one() { + let mut session = Session::new(); + session.observe("/home/dev/shop", "2026-10-09T10:00:00Z", |o| { + o.prompt("Fix the web tests") + }); + session.observe("/home/dev/shop/web", "2026-10-09T10:00:09Z", |o| { + o.command( + "Shell", + "npm test", + Ran { + output: "", + exit_code: Some(0), + failed: false, + interrupted: false, + duration_ms: None, + }, + ) + }); + + assert_eq!( + session.calls()[0].args.command.as_deref(), + Some("cd web && npm test") + ); + } + + #[test] + fn secrets_never_reach_the_journal() { + let redactor = Redactor::with_home("/home/dev"); + let observer = Observer { + session: "conv-1", + cwd: "/home/dev/shop", + at: Timestamp::UNIX_EPOCH, + model: None, + redactor: &redactor, + }; + let key = ["sk", "ant", "api03", "Ab3Zq8Lm3KpQ7vX2nB9wR4tY6uI1oP5aS0dF"].join("-"); + + let line = observer + .command( + "Shell", + &format!("ANTHROPIC_API_KEY={key} npm test"), + Ran { + output: "", + exit_code: Some(0), + failed: false, + interrupted: false, + duration_ms: None, + }, + ) + .to_line() + .expect("line encodes"); + + assert!(!line.contains(&key), "{line}"); + } + + #[test] + fn heredoc_bodies_never_reach_the_journal() { + let redactor = Redactor::with_home("/home/dev"); + let observer = Observer { + session: "conv-1", + cwd: "/home/dev/shop", + at: Timestamp::UNIX_EPOCH, + model: None, + redactor: &redactor, + }; + + let line = observer + .command( + "Shell", + "cat > config.py <<'EOF'\nDB_HOST = 'prod-db.internal'\nEOF\nnpm test", + Ran { + output: "", + exit_code: Some(0), + failed: false, + interrupted: false, + duration_ms: None, + }, + ) + .to_line() + .expect("line encodes"); + + assert!(!line.contains("prod-db.internal"), "{line}"); + } + + #[test] + fn journals_keep_only_the_prompt_summary() { + let redactor = Redactor::with_home("/home/dev"); + let observer = Observer { + session: "conv-1", + cwd: "/home/dev/shop", + at: Timestamp::UNIX_EPOCH, + model: None, + redactor: &redactor, + }; + let prompt = format!("{}\nThe private details of the task", "x".repeat(300)); + + let Observed::Prompt { text } = observer.prompt(&prompt).observed else { + panic!("a prompt observation"); + }; + + assert_eq!(text, "x".repeat(TraceBuilder::SUMMARY_CHARS)); + } + + #[test] + fn unreadable_lines_are_skipped() { + let mut session = Session::new(); + session.observe("/home/dev/shop", "2026-10-09T10:00:00Z", |o| { + o.prompt("Fix the build") + }); + session.lines.push_str("{\"type\":\"call\",\"tru\n"); + + assert_eq!(session.trace().events.len(), 1); + } +} diff --git a/crates/trodden-capture/src/kimi.rs b/crates/trodden-capture/src/kimi.rs new file mode 100644 index 0000000..b1dc986 --- /dev/null +++ b/crates/trodden-capture/src/kimi.rs @@ -0,0 +1 @@ +pub const HARNESS: &str = "kimi"; diff --git a/crates/trodden-capture/src/lib.rs b/crates/trodden-capture/src/lib.rs index e44751d..bfb82d8 100644 --- a/crates/trodden-capture/src/lib.rs +++ b/crates/trodden-capture/src/lib.rs @@ -1,8 +1,18 @@ +mod builder; pub mod claude_code; +pub mod codex; mod command; +pub mod copilot; +mod diff; +pub mod droid; mod error; mod evidence; +pub mod gemini; +pub mod journal; +pub mod kimi; +pub mod qwen; mod symbols; +pub use builder::Reminder; pub use command::Command; pub use error::ErrorSignature; diff --git a/crates/trodden-capture/src/qwen.rs b/crates/trodden-capture/src/qwen.rs new file mode 100644 index 0000000..b5f95fb --- /dev/null +++ b/crates/trodden-capture/src/qwen.rs @@ -0,0 +1,672 @@ +use std::collections::{HashMap, HashSet}; + +use anyhow::{Result, bail}; +use serde_json::Value; +use trodden_core::{ + Trace, + trace::{FileChange, ToolAction, ToolArgs}, +}; +use trodden_redact::Redactor; + +use crate::{ + builder::{Finish, Prompts, TraceBuilder}, + diff::Diff, +}; + +pub const HARNESS: &str = "qwen"; + +#[derive(Debug)] +pub struct Transcript; + +impl Transcript { + const PROMPTS: Prompts = Prompts { + skipped: &[ + "", + "", + "", + ], + pasted: None, + }; + + const BOOKKEEPING_TOOLS: &[&str] = &[ + "todo_write", + "save_memory", + "manage_memory", + "search_memory", + "skill", + "exit_plan_mode", + "enter_plan_mode", + "ask_user_question", + "cron_create", + "cron_list", + "cron_delete", + "loop_wakeup", + "list_agents", + "task_stop", + "task_create", + "task_update", + "task_list", + "send_message", + "structured_output", + "tool_search", + "get_goal", + "update_goal", + "monitor", + ]; + + pub fn parse(text: &str, redactor: &Redactor) -> Result { + let chain = Chain::new(text); + if chain.managed { + bail!("this Qwen Code session uses the managed engine, whose log is not a transcript"); + } + let mut reader = Reader { + builder: TraceBuilder::new(HARNESS, redactor), + }; + for record in chain.live() { + reader.push(record); + } + reader + .builder + .finish("no Qwen Code session found in transcript") + } + + pub fn working_directory(text: &str) -> Option { + Chain::new(text) + .live() + .into_iter() + .filter(|record| !Reader::is_sidechain(record)) + .find_map(|record| record.get("cwd").and_then(Value::as_str)) + .filter(|cwd| !cwd.is_empty()) + .map(str::to_owned) + } +} + +#[derive(Debug, Default)] +struct Chain { + order: Vec, + records: HashMap, + managed: bool, +} + +impl Chain { + fn new(text: &str) -> Self { + let mut chain = Self::default(); + for line in text.lines() { + let Ok(record @ Value::Object(_)) = serde_json::from_str::(line) else { + continue; + }; + if record + .get("subtype") + .and_then(Value::as_str) + .is_some_and(|subtype| subtype.starts_with("managed_session_")) + { + chain.managed = true; + } + let Some(uuid) = record + .get("uuid") + .and_then(Value::as_str) + .map(str::to_owned) + else { + continue; + }; + match chain.records.get_mut(&uuid) { + Some(first) => Self::merge(first, record), + None => { + chain.order.push(uuid.clone()); + chain.records.insert(uuid, record); + } + } + } + chain + } + + fn merge(first: &mut Value, later: Value) { + let Value::Object(later) = later else { return }; + for (key, value) in later { + if key == "message" { + let parts = value + .get("parts") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + match first + .get_mut("message") + .and_then(|message| message.get_mut("parts")) + .and_then(Value::as_array_mut) + { + Some(existing) => existing.extend(parts), + None => { + first["message"] = value; + } + } + } else if first.get(&key).is_none_or(Value::is_null) { + first[key.as_str()] = value; + } + } + } + + fn live(&self) -> Vec<&Value> { + let Some(mut current) = self.order.last() else { + return Vec::new(); + }; + let mut live = HashSet::new(); + while live.insert(current.as_str()) { + let Some(parent) = self + .records + .get(current) + .and_then(|record| record.get("parentUuid")) + .and_then(Value::as_str) + .and_then(|parent| self.records.get_key_value(parent)) + .map(|(uuid, _)| uuid) + else { + break; + }; + current = parent; + } + self.order + .iter() + .filter(|uuid| live.contains(uuid.as_str())) + .filter_map(|uuid| self.records.get(uuid)) + .collect() + } +} + +struct Reader<'a> { + builder: TraceBuilder<'a>, +} + +impl Reader<'_> { + fn is_sidechain(record: &Value) -> bool { + record.get("isSidechain").and_then(Value::as_bool) == Some(true) + || record.get("agentId").is_some_and(|id| !id.is_null()) + } + + fn push(&mut self, record: &Value) { + if Self::is_sidechain(record) { + return; + } + let field = |key: &str| record.get(key).and_then(Value::as_str); + if let Some(session) = field("sessionId") { + self.builder.session(session); + } + if let Some(cwd) = field("cwd") { + self.builder.directory(cwd); + } + if let Some(at) = field("timestamp") { + self.builder.time(at); + } + let subtype = field("subtype"); + match (field("type"), subtype) { + (Some("user"), None) => self.push_user(record), + (Some("assistant"), _) => self.push_assistant(record), + (Some("tool_result"), None) => self.push_result(record), + (Some("system"), Some("chat_compression")) => { + let manual = record + .get("systemPayload") + .and_then(|payload| payload.get("trigger")) + .and_then(Value::as_str) + == Some("manual"); + self.builder.compaction(!manual); + } + _ => {} + } + } + + fn parts(record: &Value) -> &[Value] { + record + .get("message") + .and_then(|message| message.get("parts")) + .and_then(Value::as_array) + .map_or(&[], Vec::as_slice) + } + + fn push_user(&mut self, record: &Value) { + let provenance = record.get("provenance").and_then(Value::as_str); + if provenance.is_some_and(|provenance| provenance != "real_user") { + return; + } + let shown = record + .get("systemPayload") + .and_then(|payload| payload.get("displayText")) + .and_then(Value::as_str); + let text = shown.or_else(|| { + Self::parts(record) + .iter() + .filter(|part| part.get("thought").and_then(Value::as_bool) != Some(true)) + .filter_map(|part| part.get("text").and_then(Value::as_str)) + .find(|text| !text.trim_start().starts_with(" Option<(ToolAction, ToolArgs)> { + let field = |key: &str| args.get(key).and_then(Value::as_str); + let builder = &self.builder; + let mut tool_args = ToolArgs::default(); + let action = match name { + "run_shell_command" => { + let command = field("command").unwrap_or_default(); + let (shell, action) = match field("directory").filter(|dir| !dir.is_empty()) { + Some(dir) => self.shell_in(dir, command), + None => self.builder.shell(command), + }; + tool_args = shell; + action + } + "edit" | "replace" | "write_file" | "notebook_edit" => { + tool_args.path = field("file_path") + .or_else(|| field("notebook_path")) + .map(|p| builder.relative(p)); + ToolAction::Edit + } + "read_file" | "list_directory" | "zoom_image" => { + tool_args.path = field("file_path") + .or_else(|| field("path")) + .or_else(|| field("absolute_path")) + .map(|p| builder.relative(p)); + ToolAction::Read + } + "grep_search" | "search_file_content" | "glob" => { + tool_args.pattern = + field("pattern").map(|p| builder.search_pattern(p, name == "glob")); + tool_args.path = field("path").map(|p| builder.relative(p)); + ToolAction::Search + } + "web_fetch" => { + tool_args.url = field("url").map(|u| builder.redact(u)); + ToolAction::Fetch + } + "web_search" => { + tool_args.query = field("query").map(|q| builder.redact(q)); + ToolAction::Fetch + } + "agent" | "task" => ToolAction::Delegate, + _ => ToolAction::Other, + }; + Some((action, tool_args)) + } + + fn shell_in(&mut self, dir: &str, command: &str) -> (ToolArgs, ToolAction) { + let root = self.builder.working_directory().to_owned(); + if root.is_empty() { + return self.builder.shell(command); + } + let here = if TraceBuilder::is_absolute(dir) { + dir.to_owned() + } else { + format!("{}/{}", root.trim_end_matches(['/', '\\']), dir) + }; + self.builder.directory(&here); + let shell = self.builder.shell(command); + self.builder.directory(&root); + shell + } + + fn push_result(&mut self, record: &Value) { + let result = record.get("toolCallResult"); + for part in Self::parts(record) { + let Some(response) = part.get("functionResponse") else { + continue; + }; + let Some(id) = response + .get("id") + .and_then(Value::as_str) + .or_else(|| result.and_then(|r| r.get("callId")).and_then(Value::as_str)) + else { + continue; + }; + if !self.builder.is_pending(id) { + continue; + } + self.resolve(id, response.get("response"), result); + } + } + + fn resolve(&mut self, id: &str, response: Option<&Value>, result: Option<&Value>) { + let field = |key: &str| result.and_then(|r| r.get(key)).and_then(Value::as_str); + let display = result.and_then(|r| r.get("resultDisplay")); + let shell = + display.filter(|d| d.get("type").and_then(Value::as_str) == Some("shell_result")); + let error_text = response.and_then(|r| r.get("error")).map(Self::string); + let output_text = response.and_then(|r| r.get("output")).map(Self::string); + let status = field("status").unwrap_or(if error_text.is_some() { + "error" + } else { + "success" + }); + let denied = field("errorType") + .is_some_and(|kind| kind == "execution_denied" || kind == "permission_denied"); + let exit_code = shell + .and_then(|s| s.get("exitCode")) + .and_then(Value::as_i64) + .and_then(|code| i32::try_from(code).ok()); + let outcome = shell.and_then(|s| s.get("outcome")).and_then(Value::as_str); + let finish = match status { + "cancelled" => Finish::Interrupted, + _ if denied || outcome == Some("cancelled") => Finish::Interrupted, + "error" => Finish::Failed { exit_code }, + _ => Finish::Succeeded, + }; + let output = shell + .and_then(|s| s.get("output")) + .and_then(Value::as_str) + .map(str::to_owned) + .or(error_text) + .or(output_text) + .unwrap_or_default(); + let changes = if finish == Finish::Succeeded { + display + .and_then(|d| self.file_change(d)) + .into_iter() + .collect() + } else { + Vec::new() + }; + self.builder.resolve(id, finish, &output, None, changes); + } + + fn string(value: &Value) -> String { + match value { + Value::String(text) => text.clone(), + other => other + .get("message") + .and_then(Value::as_str) + .map_or_else(|| other.to_string(), str::to_owned), + } + } + + fn file_change(&self, display: &Value) -> Option { + let diff = display.get("fileDiff").and_then(Value::as_str)?; + let path = display + .get("filePath") + .and_then(Value::as_str) + .or_else(|| display.get("fileName").and_then(Value::as_str))?; + let original = display.get("originalContent").and_then(Value::as_str); + let created = display.get("originalContent").is_some_and(Value::is_null) + || display.get("isNewFile").and_then(Value::as_bool) == Some(true); + Some( + self.builder + .change(path, created, &Diff::unified(diff), original), + ) + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use trodden_core::trace::{EventKind, ToolCall, ToolOutcome}; + + use super::*; + + #[derive(Debug, Default)] + struct Session { + lines: Vec, + } + + impl Session { + fn record(&mut self, kind: &str, extra: Value) -> &mut Self { + let uuid = format!("r{}", self.lines.len() + 1); + let parent = (!self.lines.is_empty()).then(|| format!("r{}", self.lines.len())); + let mut record = json!({"uuid": uuid, "parentUuid": parent, "sessionId": "6f1c2a9e", "timestamp": "2026-10-09T10:12:03.600Z", + "type": kind, "cwd": "/home/dev/shop", "version": "0.25.0"}); + record + .as_object_mut() + .expect("object") + .extend(extra.as_object().expect("object").clone()); + self.lines.push(record); + self + } + + fn user(&mut self, text: &str) -> &mut Self { + self.record("user", json!({"provenance": "real_user", "message": {"role": "user", "parts": [{"text": text}]}})) + } + + fn calls(&mut self, calls: Value) -> &mut Self { + let parts: Vec = calls + .as_array() + .expect("array") + .iter() + .map(|call| json!({"functionCall": call})) + .collect(); + self.record("assistant", json!({"provenance": "assistant_output", "model": "qwen3-coder-plus", "message": {"role": "model", "parts": parts}})) + } + + fn result(&mut self, id: &str, name: &str, response: Value, result: Value) -> &mut Self { + self.record("tool_result", json!({"provenance": "tool_result", + "message": {"role": "user", "parts": [{"functionResponse": {"id": id, "name": name, "response": response}}]}, + "toolCallResult": result})) + } + + fn shell(&mut self, id: &str, exit_code: i64, output: &str) -> &mut Self { + let status = if exit_code == 0 { "success" } else { "error" }; + let response = if exit_code == 0 { + json!({"output": format!("Command: x\nOutput: {output}")}) + } else { + json!({"error": format!("Command: x\nOutput: {output}\nExit Code: {exit_code}")}) + }; + self.result(id, "run_shell_command", response, json!({"callId": id, "status": status, + "resultDisplay": {"type": "shell_result", "version": 1, "output": output, "directory": "/home/dev/shop", "exitCode": exit_code, "outcome": if exit_code == 0 { "completed" } else { "failed" }}})) + } + + fn text(&self) -> String { + self.lines.iter().map(|line| format!("{line}\n")).collect() + } + + fn trace(&self) -> Trace { + Transcript::parse(&self.text(), &Redactor::with_home("/home/dev")) + .expect("valid transcript") + } + + fn prompts(trace: &Trace) -> Vec { + trace + .events + .iter() + .filter_map(|event| match &event.kind { + EventKind::Prompt { summary } => Some(summary.clone()), + _ => None, + }) + .collect() + } + + fn tool_calls(trace: &Trace) -> Vec { + trace + .events + .iter() + .filter_map(|event| match &event.kind { + EventKind::ToolCall(call) => Some(call.clone()), + _ => None, + }) + .collect() + } + } + + #[test] + fn only_typed_prompts_are_kept() { + let mut session = Session::default(); + session + .record("user", json!({"provenance": "real_user", + "message": {"role": "user", "parts": [{"text": "fix the migration test"}, {"text": "\n<trodden-memory>\n"}]}, + "systemPayload": {"displayText": "fix the migration test", "hookContext": "<trodden-memory>"}})) + .record("user", json!({"subtype": "notification", "provenance": "system", "message": {"role": "user", "parts": [{"text": "Background task done"}]}})) + .record("user", json!({"subtype": "cron", "provenance": "system", "message": {"role": "user", "parts": [{"text": "check CI"}]}})) + .record("user", json!({"provenance": "goal_runtime", "message": {"role": "user", "parts": [{"text": "continue the goal"}]}})) + .record("system", json!({"subtype": "slash_command", "systemPayload": {"phase": "invocation", "rawCommand": "/compress"}})) + .user("Trodden: the procedure recalled for this task is checked with `npm test`, which has not run since your last change.") + .user("now the sorting bug"); + let trace = session.trace(); + + assert_eq!( + Session::prompts(&trace), + ["fix the migration test", "now the sorting bug"] + ); + assert_eq!(trace.session.as_str(), "6f1c2a9e"); + assert_eq!(trace.cwd, "~/shop"); + assert_eq!(trace.harness.as_str(), HARNESS); + } + + #[test] + fn shell_outcomes_use_the_recorded_exit_code() { + let mut session = Session::default(); + session + .user("fix the db tests") + .calls(json!([ + {"id": "call_1", "name": "run_shell_command", "args": {"command": "cargo test -p db", "is_background": false}}, + {"id": "call_2", "name": "run_shell_command", "args": {"command": "cargo test -p db", "directory": "crates/db"}}, + {"id": "call_3", "name": "run_shell_command", "args": {"command": "sleep 99"}}, + {"id": "call_4", "name": "run_shell_command", "args": {"command": "rm -rf build"}}, + {"id": "call_5", "name": "run_shell_command", "args": {"command": "npm run dev"}} + ])) + .shell("call_1", 101, "error[E0432]: unresolved import `sqlx::migrate`") + .shell("call_2", 0, "test result: ok. 4 passed; 0 failed") + .result("call_3", "run_shell_command", json!({"error": "cancelled"}), json!({"callId": "call_3", "status": "cancelled"})) + .result("call_4", "run_shell_command", json!({"error": "denied"}), json!({"callId": "call_4", "status": "error", "errorType": "execution_denied"})); + let calls = Session::tool_calls(&session.trace()); + + assert_eq!( + calls[0].outcome, + ToolOutcome::Failed { + exit_code: Some(101) + } + ); + assert!( + calls[0] + .error + .as_deref() + .is_some_and(|e| e.contains("sqlx")), + "{:?}", + calls[0].error + ); + assert_eq!(calls[1].outcome, ToolOutcome::Succeeded); + assert_eq!( + calls[1].args.command.as_deref(), + Some("cd crates/db && cargo test -p db") + ); + assert_eq!(calls[2].outcome, ToolOutcome::Interrupted); + assert_eq!(calls[3].outcome, ToolOutcome::Interrupted); + assert_eq!(calls[4].outcome, ToolOutcome::Interrupted); + } + + #[test] + fn edits_count_lines_and_skip_failed_ones() { + let mut session = Session::default(); + session + .user("fix paging") + .calls(json!([ + {"id": "e1", "name": "edit", "args": {"file_path": "/home/dev/shop/src/paginate.js", "old_string": "+ 1;", "new_string": ";"}}, + {"id": "e2", "name": "write_file", "args": {"file_path": "/home/dev/shop/test/p.test.js", "content": "a\nb\n"}}, + {"id": "e3", "name": "edit", "args": {"file_path": "/home/dev/shop/src/x.js", "old_string": "q", "new_string": "r"}} + ])) + .result("e1", "edit", json!({"output": "ok"}), json!({"callId": "e1", "status": "success", + "resultDisplay": {"fileDiff": "--- paginate.js\n+++ paginate.js\n@@ -3,2 +3,2 @@\n- const end = start + perPage + 1;\n+ const end = start + perPage;\n", "fileName": "paginate.js", "filePath": "/home/dev/shop/src/paginate.js", "originalContent": "x", "newContent": "y"}})) + .result("e2", "write_file", json!({"output": "ok"}), json!({"callId": "e2", "status": "success", + "resultDisplay": {"fileDiff": "@@ -0,0 +1,2 @@\n+a\n+b\n", "fileName": "p.test.js", "filePath": "/home/dev/shop/test/p.test.js", "originalContent": null, "newContent": "a\nb\n"}})) + .result("e3", "edit", json!({"error": "could not find the string"}), json!({"callId": "e3", "status": "error"})); + let calls = Session::tool_calls(&session.trace()); + + assert_eq!(calls[0].args.path.as_deref(), Some("src/paginate.js")); + let change = &calls[0].changes[0]; + assert_eq!( + ( + change.path.as_str(), + change.lines_added, + change.lines_removed, + change.created + ), + ("src/paginate.js", 1, 1, false) + ); + assert_eq!( + (calls[1].changes[0].lines_added, calls[1].changes[0].created), + (2, true) + ); + assert_eq!(calls[2].outcome, ToolOutcome::Failed { exit_code: None }); + assert!(calls[2].changes.is_empty()); + } + + #[test] + fn rewound_branches_and_sidechains_are_left_out() { + let mut session = Session::default(); + session.user("first attempt").calls( + json!([{"id": "c1", "name": "run_shell_command", "args": {"command": "npm test"}}]), + ); + session.shell("c1", 0, "ok"); + session + .record("assistant", json!({"isSidechain": true, "agentId": "a1", "message": {"role": "model", "parts": [{"functionCall": {"id": "s1", "name": "run_shell_command", "args": {"command": "ls"}}}]}})); + session.lines.push(json!({"uuid": "n1", "parentUuid": null, "sessionId": "6f1c2a9e", "timestamp": "2026-10-09T10:20:00Z", "type": "user", "provenance": "real_user", "cwd": "/home/dev/shop", "message": {"role": "user", "parts": [{"text": "second attempt"}]}})); + session.lines.push(json!({"uuid": "n2", "parentUuid": "n1", "sessionId": "6f1c2a9e", "timestamp": "2026-10-09T10:20:01Z", "type": "assistant", "cwd": "/home/dev/shop", "message": {"role": "model", "parts": [{"text": "On it."}]}})); + let trace = session.trace(); + + assert_eq!(Session::prompts(&trace), ["second attempt"]); + assert!(Session::tool_calls(&trace).is_empty()); + } + + #[test] + fn fragments_of_one_record_merge() { + let mut session = Session::default(); + session.user("run tests").calls( + json!([{"id": "c1", "name": "run_shell_command", "args": {"command": "npm test"}}]), + ); + let mut fragment = session.lines.last().expect("record").clone(); + fragment["message"]["parts"] = json!([{"functionCall": {"id": "c2", "name": "run_shell_command", "args": {"command": "npm run lint"}}}]); + session.lines.push(fragment); + let trace = session.trace(); + + assert_eq!(Session::tool_calls(&trace).len(), 2); + } + + #[test] + fn compression_and_malformed_lines() { + let mut session = Session::default(); + session + .user("fix paging") + .record("system", json!({"subtype": "chat_compression", "provenance": "system", "systemPayload": {"info": {}, "compressedHistory": [{"role": "user", "parts": [{"text": ""}]}]}})); + let mut text = session.text(); + text.insert_str(0, "garbage\n{\"uuid\": 5}\n"); + let trace = Transcript::parse(&text, &Redactor::with_home("/home/dev")).expect("parses"); + + assert!( + trace + .events + .iter() + .any(|event| matches!(event.kind, EventKind::Compaction { automatic: true })) + ); + assert_eq!( + Transcript::working_directory(&text).as_deref(), + Some("/home/dev/shop") + ); + assert!(Transcript::parse("nothing\n", &Redactor::with_home("/home/dev")).is_err()); + } + + #[test] + fn managed_engine_logs_are_refused() { + let mut session = Session::default(); + session.record("system", json!({"subtype": "managed_session_header_v1"})); + + assert!(Transcript::parse(&session.text(), &Redactor::with_home("/home/dev")).is_err()); + } +} diff --git a/crates/trodden-redact/src/lib.rs b/crates/trodden-redact/src/lib.rs index 953daea..3e2da2d 100644 --- a/crates/trodden-redact/src/lib.rs +++ b/crates/trodden-redact/src/lib.rs @@ -400,6 +400,15 @@ impl Redactor { #[must_use] pub fn redact<'a>(&self, text: &'a str) -> Cow<'a, str> { + let text = self.redact_secrets(text); + match &self.home { + Some(home) if text.contains(home.as_str()) => Cow::Owned(Self::fold_home(&text, home)), + _ => text, + } + } + + #[must_use] + pub fn redact_secrets<'a>(&self, text: &'a str) -> Cow<'a, str> { let mut text = Cow::Borrowed(text); for rule in &self.rules { if let Cow::Owned(replaced) = rule.regex.replace_all(&text, |caps: &Captures<'_>| { @@ -418,22 +427,11 @@ impl Redactor { { text = Cow::Owned(replaced); } - match &self.home { - Some(home) if text.contains(home.as_str()) => Cow::Owned(Self::fold_home(&text, home)), - _ => text, - } + text } pub fn contains_secret(&self, text: &str) -> bool { - self.redact_secrets_only(text) != text - } - - fn redact_secrets_only(&self, text: &str) -> String { - let without_home = Self { - home: None, - ..self.clone() - }; - without_home.redact(text).into_owned() + self.redact_secrets(text) != text } fn replace_secret(rule: &str, caps: &Captures<'_>) -> String { diff --git a/crates/trodden-store/src/ingest.rs b/crates/trodden-store/src/ingest.rs index 555c40c..ac55828 100644 --- a/crates/trodden-store/src/ingest.rs +++ b/crates/trodden-store/src/ingest.rs @@ -77,6 +77,13 @@ impl Store { .context("read open sessions") } + pub fn forget_session(&self, session: &str) -> Result<()> { + self.conn + .execute("DELETE FROM sessions WHERE session = ?1", [session]) + .context("forget a session")?; + Ok(()) + } + pub fn end_session(&self, session: &str, at: &str) -> Result<()> { self.conn .execute( diff --git a/crates/trodden-store/src/learning.rs b/crates/trodden-store/src/learning.rs index ddc725b..7365a61 100644 --- a/crates/trodden-store/src/learning.rs +++ b/crates/trodden-store/src/learning.rs @@ -45,6 +45,7 @@ pub struct InjectionRecord { pub injection: Injection, pub task: Option, pub outcome: Option, + pub reminded: bool, } #[derive(Debug, Clone, Default, PartialEq, Eq)] @@ -117,7 +118,8 @@ impl Store { let mut statement = self .conn .prepare( - "SELECT rowid, session, procedure, revision, holdout, cue, at, task, outcome + "SELECT rowid, session, procedure, revision, holdout, cue, at, task, outcome, + reminded_at IS NOT NULL FROM injections WHERE ?1 IS NULL OR session = ?1 ORDER BY rowid", ) .context("prepare the injection listing")?; @@ -133,11 +135,12 @@ impl Store { row.get::<_, String>(6)?, row.get::<_, Option>(7)?, row.get::<_, Option>(8)?, + row.get::<_, bool>(9)?, )) }) .context("list injections")?; rows.map(|row| { - let (rowid, session, procedure, revision, holdout, cue, at, task, outcome) = + let (rowid, session, procedure, revision, holdout, cue, at, task, outcome, reminded) = row.context("read an injection")?; Ok(InjectionRecord { rowid, @@ -151,11 +154,22 @@ impl Store { }, task, outcome, + reminded, }) }) .collect() } + pub fn mark_reminded(&self, rowid: i64, at: Timestamp) -> Result<()> { + self.conn + .execute( + "UPDATE injections SET reminded_at = ?2 WHERE rowid = ?1", + params![rowid, at.to_string()], + ) + .context("record the verify reminder")?; + Ok(()) + } + pub fn settle_injection( &self, record: &InjectionRecord, @@ -514,6 +528,36 @@ impl Store { mod tests { use super::*; + #[test] + fn a_reminder_is_remembered_per_injection() { + let store = Store::open_in_memory().expect("store opens"); + for session in ["s", "s", "t"] { + store + .record_injection(&Injection { + session: session.to_owned(), + procedure: "p_7f3a91c2".to_owned(), + revision: 1, + holdout: false, + cue: Cue::Prompt, + at: Timestamp::UNIX_EPOCH, + }) + .expect("recorded"); + } + let first = store.injections(Some("s")).expect("injections read")[0].rowid; + + store + .mark_reminded(first, Timestamp::UNIX_EPOCH) + .expect("reminder recorded"); + + let reminded: Vec = store + .injections(None) + .expect("injections read") + .iter() + .map(|record| record.reminded) + .collect(); + assert_eq!(reminded, [true, false, false]); + } + #[test] fn a_failed_settlement_leaves_the_injection_for_the_next_run() { let mut store = Store::open_in_memory().expect("store opens"); diff --git a/crates/trodden-store/src/schema.rs b/crates/trodden-store/src/schema.rs index d23b91a..92692f1 100644 --- a/crates/trodden-store/src/schema.rs +++ b/crates/trodden-store/src/schema.rs @@ -116,6 +116,9 @@ const MIGRATIONS: &[&str] = &[ ALTER TABLE extractions ADD COLUMN tool_calls INTEGER; ALTER TABLE extractions ADD COLUMN started_at TEXT; ALTER TABLE extractions ADD COLUMN ended_at TEXT; +", + r" + ALTER TABLE injections ADD COLUMN reminded_at TEXT; ", ]; diff --git a/crates/trodden/src/cli.rs b/crates/trodden/src/cli.rs index 8496f42..b9e9ca2 100644 --- a/crates/trodden/src/cli.rs +++ b/crates/trodden/src/cli.rs @@ -10,7 +10,7 @@ use std::{ use anyhow::{Context, Result, bail, ensure}; use clap::{Parser, Subcommand}; -use trodden::{Harness, Home, Ingest, IngestReport, Workspace}; +use trodden::{Change, Harness, Home, Ingest, IngestReport, Program, Workspace}; use trodden_core::RepoId; use trodden_embed::ModelPack; use trodden_recall::{Abstention, Decision, Envelope, Match, Outcome, Query}; @@ -102,18 +102,43 @@ enum Command { #[arg(long, help = "Confirm deleting everything")] yes: bool, }, - #[command(about = "Learn from existing Claude Code transcripts")] + #[command(about = "Install Trodden's hooks into a coding agent, or list the agents")] + Connect { + #[arg(help = "Agent to connect; omit to list every supported agent")] + agent: Option, + #[arg(long, help = "Show the files that would change without changing them")] + dry_run: bool, + }, + #[command(about = "Remove Trodden's hooks from a coding agent")] + Disconnect { + #[arg(help = "Agent to disconnect")] + agent: Harness, + }, + #[command(about = "Learn from existing agent sessions")] Backfill { #[arg( long, - help = "Claude Code's projects directory [default: ~/.claude/projects]" + help = "Only this agent [default: every agent with sessions on disk]" + )] + agent: Option, + #[arg( + long, + alias = "projects", + requires = "agent", + help = "Where the agent keeps its sessions [default: the agent's own directory]" )] - projects: Option, + from: Option, }, #[command(about = "Learn from one transcript. Hooks run this in the background")] Ingest { - #[arg(help = "Claude Code transcript (`.jsonl`)")] + #[arg(help = "Session transcript")] transcript: PathBuf, + #[arg( + long, + default_value = "claude-code", + help = "Agent that wrote the transcript" + )] + agent: Harness, #[arg(long, help = "The session has ended, so its last task is complete")] ended: bool, #[arg(long, help = "Print nothing")] @@ -132,9 +157,11 @@ enum Command { }, #[command(about = "Serve read-only MCP tools over stdio")] Mcp, - #[command(about = "Handle a harness hook (used by the Claude Code plugin)")] + #[command( + about = "Handle an agent hook (installed by `trodden connect` and the Claude Code plugin)" + )] Hook { - #[arg(help = "Harness name")] + #[arg(help = "Agent name")] harness: Harness, }, } @@ -199,7 +226,14 @@ impl Cli { impl Command { fn execute(self, out: &mut Output) -> Result<()> { let home = Home::locate()?; - if !matches!(self, Self::Init | Self::Doctor | Self::Hook { .. }) { + if !matches!( + self, + Self::Init + | Self::Doctor + | Self::Hook { .. } + | Self::Connect { .. } + | Self::Disconnect { .. } + ) { ensure!(home.is_initialized(), "run `trodden init` first"); } match self { @@ -225,17 +259,23 @@ impl Command { Self::Forget { id, repo, all, .. } => { Self::forget(&home, out, id.as_deref(), repo, all) } - Self::Backfill { projects } => Self::backfill(&home, out, projects), + Self::Connect { agent, dry_run } => match agent { + Some(agent) => Self::connect(out, agent, dry_run), + None => Self::agents(out), + }, + Self::Disconnect { agent } => Self::disconnect(out, agent), + Self::Backfill { agent, from } => Self::backfill(&home, out, agent, from), Self::Ingest { transcript, + agent, ended, quiet, background, } => { let report = if background { - Self::ingest_in_background(&home, &transcript, ended) + Self::ingest_in_background(&home, agent, &transcript, ended) } else { - Some(Ingest::run(&home, Harness::ClaudeCode, &transcript, ended)?) + Some(Ingest::run(&home, agent, &transcript, ended)?) }; if let Some(report) = report.filter(|_| !quiet) { Self::print_report(out, &report)?; @@ -259,7 +299,7 @@ impl Command { if fresh { writeln!( out, - "\nTrodden now learns from Claude Code sessions in this account. It stores\n\ + "\nTrodden now learns from the sessions of the agents you connect. It stores\n\ the first line of each prompt (up to 200 characters), commands, file paths,\n\ touched function names, exit codes and one normalized line per error, with\n\ secrets redacted. It never stores file contents, other tool output or the\n\ @@ -268,9 +308,18 @@ impl Command { )?; } writeln!(out, "\nNext steps:")?; - writeln!(out, " 1. Install the Claude Code plugin:")?; - writeln!(out, " claude plugin marketplace add aayank13/trodden")?; - writeln!(out, " claude plugin install trodden@trodden")?; + writeln!(out, " 1. Connect your coding agents:")?; + let detected: Vec = Harness::all() + .iter() + .copied() + .filter(|agent| agent.detected()) + .collect(); + for agent in &detected { + writeln!(out, " trodden connect {}", agent.as_str())?; + } + if detected.is_empty() { + writeln!(out, " trodden connect (lists the supported agents)")?; + } if home.embedder().is_none() { writeln!( out, @@ -296,6 +345,20 @@ impl Command { "Capture and recall {}", if store.paused()? { "paused" } else { "on" } )?; + let connected: Vec<&str> = Harness::all() + .iter() + .filter(|agent| agent.connected().unwrap_or(false)) + .map(|agent| agent.as_str()) + .collect(); + writeln!( + out, + "Agents {}", + if connected.is_empty() { + "none connected (run `trodden connect`)".to_owned() + } else { + connected.join(", ") + } + )?; writeln!( out, "Semantic matching {}", @@ -363,7 +426,20 @@ impl Command { }) .into(), )?; - for program in ["trodden", "git", "claude"] { + for agent in Harness::all().iter().filter(|agent| agent.detected()) { + report( + &format!("agent {}", agent.as_str()), + match agent.connected() { + Ok(true) => Finding::Healthy("connected".to_owned()), + Ok(false) => Finding::Note(format!( + "installed but not connected (run `trodden connect {}`)", + agent.as_str() + )), + Err(error) => Finding::Problem(error), + }, + )?; + } + for program in ["trodden", "git"] { report( &format!("`{program}` on PATH"), ExecutableSearch::from_env() @@ -662,6 +738,11 @@ impl Command { if matches!(target, Forget::All) && log.exists() { fs::remove_file(&log).with_context(|| format!("remove {}", log.display()))?; } + let journals = home.journals(); + if matches!(target, Forget::All) && journals.exists() { + fs::remove_dir_all(&journals) + .with_context(|| format!("remove {}", journals.display()))?; + } ingest.rebuild_index()?; writeln!(out, "Deleted {deleted} revision(s).")?; Ok(()) @@ -688,14 +769,37 @@ impl Command { fn backfill( home: &Home, out: &mut Output, - projects: Option, + agent: Option, + from: Option, ) -> Result<()> { - let harness = Harness::ClaudeCode; - let history = match projects { - Some(projects) => projects, - None => harness.history_dir()?, - }; - let (report, failures) = Ingest::start(home)?.backfill(harness, &history)?; + let mut sources = Vec::new(); + match (agent, from) { + (Some(agent), Some(from)) => sources.push((agent, from)), + (Some(agent), None) => match agent.history_dir()? { + Some(history) => sources.push((agent, history)), + None => bail!( + "{} keeps no sessions Trodden can read; it learns from them as they happen", + agent.title() + ), + }, + (None, _) => { + for agent in Harness::all() { + if let Some(history) = agent.history_dir()?.filter(|dir| dir.exists()) { + sources.push((*agent, history)); + } + } + } + } + let mut ingest = Ingest::start(home)?; + let mut report = IngestReport::default(); + let mut failures = Vec::new(); + for (agent, history) in sources { + let (one, failed) = ingest.backfill(agent, &history).with_context(|| { + format!("backfill {} from {}", agent.title(), history.display()) + })?; + report.absorb(one); + failures.extend(failed); + } Self::print_report(out, &report)?; let mut err = Output::stderr(); for (transcript, error) in &failures { @@ -704,8 +808,76 @@ impl Command { Ok(()) } - fn ingest_in_background(home: &Home, transcript: &Path, ended: bool) -> Option { - Ingest::run_or_defer(home, Harness::ClaudeCode, transcript, ended) + fn agents(out: &mut Output) -> Result<()> { + writeln!( + out, + "{:<12} {:<20} {:<10} CONNECTED", + "AGENT", "NAME", "INSTALLED" + )?; + for agent in Harness::all() { + let connected = match agent.connected() { + Ok(true) => "yes".to_owned(), + Ok(false) => "no".to_owned(), + Err(error) => format!("unknown ({error:#})"), + }; + writeln!( + out, + "{:<12} {:<20} {:<10} {connected}", + agent.as_str(), + agent.title(), + if agent.detected() { "yes" } else { "no" }, + )?; + } + writeln!(out, "\nRun `trodden connect ` to install the hooks.")?; + Ok(()) + } + + fn connect(out: &mut Output, agent: Harness, dry_run: bool) -> Result<()> { + let changes = agent.connect(&Program::current()?)?; + Self::apply(out, &changes, dry_run)?; + if !dry_run { + writeln!(out, "Connected {}.", agent.title())?; + if let Some(notice) = agent.notice() { + writeln!(out, "{notice}")?; + } + } + Ok(()) + } + + fn disconnect(out: &mut Output, agent: Harness) -> Result<()> { + let changes = agent.disconnect()?; + Self::apply(out, &changes, false)?; + writeln!(out, "Disconnected {}.", agent.title())?; + Ok(()) + } + + fn apply(out: &mut Output, changes: &[Change], dry_run: bool) -> Result<()> { + for change in changes.iter().filter(|change| change.is_needed()) { + let verb = if change.contents.is_some() { + "write" + } else { + "remove" + }; + if dry_run { + writeln!(out, "would {verb} {}", change.path.display())?; + if let Some(contents) = &change.contents { + writeln!(out, "{contents}")?; + } + } else { + change.apply()?; + writeln!(out, "{verb} {}", change.path.display())?; + } + } + Ok(()) + } + + fn ingest_in_background( + home: &Home, + agent: Harness, + transcript: &Path, + ended: bool, + ) -> Option { + Ingest::run_or_defer(home, agent, transcript, ended) .with_context(|| format!("ingest {}", transcript.display())) .unwrap_or_else(|error| { home.log_error(error); @@ -925,6 +1097,10 @@ mod tests { "2026-10-01T10:00:00Z parse the hook payload\n", ) .expect("error log is writable"); + let journal = home.journal("cursor", "conv-1"); + fs::create_dir_all(journal.parent().expect("journals have a directory")) + .expect("journal directory is writable"); + fs::write(&journal, "{}\n").expect("journal is writable"); home.open_store(Patience::Batch) .expect("store opens") .remember_repo("/home/dev/shop", "path-5f0c1d2e3a4b6978") @@ -939,6 +1115,7 @@ mod tests { "Deleted 0 revision(s).\n" ); assert!(!home.error_log().exists()); + assert!(!home.journals().exists()); assert_eq!( home.open_store(Patience::Batch) .expect("store opens") @@ -1000,7 +1177,7 @@ mod tests { fs::write(home.pending_ingests(), "").expect("queue path is writable"); let transcript = home.dir().join("session.jsonl"); - let report = Command::ingest_in_background(home, &transcript, true); + let report = Command::ingest_in_background(home, Harness::ClaudeCode, &transcript, true); assert_eq!(report, None); let log = fs::read_to_string(home.error_log()).expect("error log reads"); diff --git a/crates/trodden/src/connect.rs b/crates/trodden/src/connect.rs new file mode 100644 index 0000000..e6c1042 --- /dev/null +++ b/crates/trodden/src/connect.rs @@ -0,0 +1,469 @@ +use std::{ + env, fs, + io::ErrorKind, + path::{Path, PathBuf}, + process, +}; + +use anyhow::{Context, Result, bail}; +use serde_json::{Map, Value}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Program { + path: PathBuf, +} + +impl Program { + pub fn current() -> Result { + Ok(Self::at( + env::current_exe().context("find the trodden executable")?, + )) + } + + pub fn at(path: impl Into) -> Self { + Self { path: path.into() } + } + + pub fn path(&self) -> &Path { + &self.path + } + + // Absolute path: agents started from a GUI or a login shell often lack `~/.cargo/bin` on + // PATH. + pub fn hook(&self, harness: &str) -> String { + format!( + "{} hook {harness}", + Self::quoted(&self.path.to_string_lossy()) + ) + } + + fn quoted(text: &str) -> String { + if !text.is_empty() + && text + .chars() + .all(|c| c.is_ascii_alphanumeric() || "._-/+@%,:=".contains(c)) + { + text.to_owned() + } else { + format!("'{}'", text.replace('\'', r"'\''")) + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Change { + pub path: PathBuf, + pub contents: Option, + pub executable: bool, +} + +impl Change { + pub fn write(path: impl Into, contents: impl Into) -> Self { + Self { + path: path.into(), + contents: Some(contents.into()), + executable: false, + } + } + + pub fn remove(path: impl Into) -> Self { + Self { + path: path.into(), + contents: None, + executable: false, + } + } + + #[must_use] + pub fn executable(mut self) -> Self { + self.executable = true; + self + } + + pub fn is_needed(&self) -> bool { + match (&self.contents, fs::read_to_string(&self.path)) { + (Some(contents), Ok(current)) => *contents != current, + (Some(_), Err(_)) => true, + (None, _) => self.path.exists(), + } + } + + // Replaced in one rename so the agent never reads half a file; symlinks are written through + // so dotfile links survive. + pub fn apply(&self) -> Result<()> { + let is_link = + fs::symlink_metadata(&self.path).is_ok_and(|meta| meta.file_type().is_symlink()); + if is_link && self.contents.is_some() { + let target = fs::canonicalize(&self.path) + .with_context(|| format!("follow the link {}", self.path.display()))?; + return Self { + path: target, + ..self.clone() + } + .apply(); + } + let Some(contents) = &self.contents else { + return match fs::remove_file(&self.path) { + Err(error) if error.kind() != ErrorKind::NotFound => { + Err(error).with_context(|| format!("remove {}", self.path.display())) + } + _ => Ok(()), + }; + }; + if let Some(parent) = self.path.parent() { + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + let staged = self + .path + .with_extension(format!("trodden-{}.tmp", process::id())); + fs::write(&staged, contents).with_context(|| format!("write {}", staged.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = if self.executable { + 0o755 + } else { + fs::metadata(&self.path).map_or(0o644, |metadata| metadata.permissions().mode()) + }; + fs::set_permissions(&staged, fs::Permissions::from_mode(mode)) + .with_context(|| format!("set the permissions of {}", staged.display()))?; + } + fs::rename(&staged, &self.path).with_context(|| format!("replace {}", self.path.display())) + } +} + +// Only handlers whose command is `trodden ... hook ` are ever added or removed. +#[derive(Debug, Clone)] +pub(crate) struct HookFile { + path: PathBuf, + original: Option, + value: Value, +} + +impl HookFile { + pub(crate) fn load(path: impl Into) -> Result { + let path = path.into(); + let original = match fs::read_to_string(&path) { + Ok(text) => Some(text), + Err(error) if error.kind() == ErrorKind::NotFound => None, + Err(error) => return Err(error).with_context(|| format!("read {}", path.display())), + }; + let value = match original.as_deref().map(str::trim) { + None | Some("") => Value::Object(Map::new()), + Some(text) => match serde_json::from_str::(text) { + Ok(value @ Value::Object(_)) => value, + _ => bail!( + "{} is not a plain JSON object (comments are not supported); add the hooks by hand", + path.display() + ), + }, + }; + Ok(Self { + path, + original, + value, + }) + } + + pub(crate) fn is_ours(command: &str, harness: &str) -> bool { + command.contains("trodden") && command.contains(&format!(" hook {harness}")) + } + + fn object<'v>(value: &'v mut Value, path: &[&str]) -> Result<&'v mut Map> { + let mut current = value; + for key in path { + let Value::Object(map) = current else { + bail!("expected `{key}` to be inside a JSON object"); + }; + current = map + .entry((*key).to_owned()) + .or_insert_with(|| Value::Object(Map::new())); + } + match current { + Value::Object(map) => Ok(map), + _ => bail!("expected `{}` to be a JSON object", path.join(".")), + } + } + + pub(crate) fn get(&self, key: &str) -> Option<&Value> { + self.value.get(key) + } + + pub(crate) fn set(&mut self, key: &str, value: Value) -> Result<()> { + Self::object(&mut self.value, &[])?.insert(key.to_owned(), value); + Ok(()) + } + + pub(crate) fn add(&mut self, root: &[&str], event: &str, entry: Value) -> Result<()> { + let events = Self::object(&mut self.value, root) + .with_context(|| format!("add a hook to {}", self.path.display()))?; + let entries = events + .entry(event.to_owned()) + .or_insert_with(|| Value::Array(Vec::new())); + let Value::Array(entries) = entries else { + bail!( + "expected the `{event}` hooks in {} to be a list", + self.path.display() + ); + }; + entries.push(entry); + Ok(()) + } + + pub(crate) fn remove(&mut self, root: &[&str], harness: &str) -> bool { + let mut current = &mut self.value; + for key in root { + match current.get_mut(*key) { + Some(next) => current = next, + None => return false, + } + } + let Value::Object(events) = current else { + return false; + }; + let mut removed = false; + for entries in events.values_mut() { + let Value::Array(entries) = entries else { + continue; + }; + entries.retain_mut(|entry| { + if Self::handler_is_ours(entry, harness) { + removed = true; + return false; + } + let Some(Value::Array(handlers)) = entry.get_mut("hooks") else { + return true; + }; + let before = handlers.len(); + handlers.retain(|handler| !Self::handler_is_ours(handler, harness)); + removed |= handlers.len() != before; + !(handlers.is_empty() && before > 0) + }); + } + events.retain(|_, entries| !matches!(entries, Value::Array(list) if list.is_empty())); + if removed && events.is_empty() { + self.prune(root); + } + removed + } + + fn prune(&mut self, root: &[&str]) { + let Some((last, parents)) = root.split_last() else { + return; + }; + let mut current = &mut self.value; + for key in parents { + match current.get_mut(*key) { + Some(next) => current = next, + None => return, + } + } + if let Value::Object(map) = current { + map.remove(*last); + } + } + + pub(crate) fn contains(&self, root: &[&str], harness: &str) -> bool { + let mut copy = self.clone(); + copy.remove(root, harness) + } + + fn handler_is_ours(handler: &Value, harness: &str) -> bool { + ["command", "bash", "powershell"].iter().any(|key| { + handler + .get(key) + .and_then(Value::as_str) + .is_some_and(|command| Self::is_ours(command, harness)) + }) + } + + pub(crate) fn change(&self) -> Result> { + let mut text = + serde_json::to_string_pretty(&self.value).context("encode the hook settings")?; + text.push('\n'); + let unchanged = self.original.as_deref().is_some_and(|original| { + serde_json::from_str::(original).is_ok_and(|value| value == self.value) + }) || (self.original.is_none() && self.value == Value::Object(Map::new())); + Ok((!unchanged).then(|| Change::write(&self.path, text))) + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[derive(Debug)] + struct Settings { + dir: PathBuf, + } + + impl Settings { + fn new(name: &str) -> Self { + let dir = env::temp_dir().join(format!("trodden-connect-{name}-{}", process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("scratch dir is writable"); + Self { dir } + } + + fn file(&self, contents: &Value) -> PathBuf { + let path = self.dir.join("settings.json"); + fs::write(&path, contents.to_string()).expect("settings are writable"); + path + } + + fn read(path: &Path) -> Value { + serde_json::from_str(&fs::read_to_string(path).expect("settings are readable")) + .expect("settings are JSON") + } + } + + impl Drop for Settings { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.dir); + } + } + + #[test] + fn programs_are_quoted_only_when_needed() { + assert_eq!( + Program::at("/home/dev/.cargo/bin/trodden").hook("codex"), + "/home/dev/.cargo/bin/trodden hook codex" + ); + assert_eq!( + Program::at("/Users/dev/Application Support/it's/trodden").hook("codex"), + r"'/Users/dev/Application Support/it'\''s/trodden' hook codex" + ); + } + + #[test] + fn our_handlers_are_replaced_and_others_kept() { + let settings = Settings::new("replace"); + let path = settings.file(&json!({ + "theme": "dark", + "hooks": { + "SessionStart": [ + {"hooks": [{"type": "command", "command": "bash ~/.codex/herdr.sh session"}]}, + {"hooks": [{"type": "command", "command": "/old/trodden hook codex"}]} + ], + "Stop": [{"hooks": [ + {"type": "command", "command": "notify-send done"}, + {"type": "command", "command": "/old/trodden hook codex"} + ]}] + } + })); + let mut file = HookFile::load(&path).expect("settings load"); + + assert!(file.remove(&["hooks"], "codex")); + file.add( + &["hooks"], + "Stop", + json!({"hooks": [{"type": "command", "command": "/new/trodden hook codex"}]}), + ) + .expect("hook is added"); + file.change() + .expect("settings encode") + .expect("settings changed") + .apply() + .expect("settings are written"); + + assert_eq!( + Settings::read(&path), + json!({ + "theme": "dark", + "hooks": { + "SessionStart": [ + {"hooks": [{"type": "command", "command": "bash ~/.codex/herdr.sh session"}]} + ], + "Stop": [ + {"hooks": [{"type": "command", "command": "notify-send done"}]}, + {"hooks": [{"type": "command", "command": "/new/trodden hook codex"}]} + ] + } + }) + ); + } + + #[test] + fn flat_handlers_are_removed_too() { + let settings = Settings::new("flat"); + let path = settings.file(&json!({ + "version": 1, + "hooks": {"stop": [{"command": "'/opt/trodden' hook cursor"}, {"command": "afplay done.aiff"}]} + })); + let mut file = HookFile::load(&path).expect("settings load"); + + assert!(file.contains(&["hooks"], "cursor")); + assert!(!file.contains(&["hooks"], "codex")); + file.remove(&["hooks"], "cursor"); + + assert_eq!( + file.value, + json!({"version": 1, "hooks": {"stop": [{"command": "afplay done.aiff"}]}}) + ); + } + + #[test] + fn an_unchanged_file_needs_no_change() { + let settings = Settings::new("unchanged"); + let path = settings.file(&json!({"hooks": {}})); + let file = HookFile::load(&path).expect("settings load"); + + assert_eq!(file.change().expect("settings encode"), None); + let missing = HookFile::load(settings.dir.join("missing.json")).expect("missing loads"); + assert_eq!(missing.change().expect("settings encode"), None); + } + + #[test] + fn files_with_comments_are_left_alone() { + let settings = Settings::new("comments"); + let path = settings.dir.join("settings.json"); + fs::write(&path, "{\n // theme\n \"theme\": \"dark\"\n}\n").expect("written"); + + let error = HookFile::load(&path).expect_err("comments are refused"); + + assert!( + format!("{error:#}").contains("add the hooks by hand"), + "{error:#}" + ); + } + + #[cfg(unix)] + #[test] + fn symlinked_settings_are_written_through_to_their_target() { + let settings = Settings::new("symlink"); + let target = settings.file(&json!({"theme": "dark"})); + let link = settings.dir.join("linked.json"); + std::os::unix::fs::symlink(&target, &link).expect("symlink is created"); + + Change::write(&link, "{}\n").apply().expect("written"); + + assert!( + fs::symlink_metadata(&link) + .expect("link exists") + .file_type() + .is_symlink() + ); + assert_eq!(fs::read_to_string(&target).expect("target reads"), "{}\n"); + } + + #[test] + fn written_files_keep_their_permissions() { + let settings = Settings::new("permissions"); + let path = settings.file(&json!({})); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).expect("chmod"); + Change::write(&path, "{}\n").apply().expect("written"); + let mode = fs::metadata(&path).expect("stat").permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + } + Change::remove(&path).apply().expect("removed"); + assert!(!path.exists()); + Change::remove(&path) + .apply() + .expect("removing twice is fine"); + } +} diff --git a/crates/trodden/src/harness.rs b/crates/trodden/src/harness.rs index 077260b..de22e99 100644 --- a/crates/trodden/src/harness.rs +++ b/crates/trodden/src/harness.rs @@ -1,45 +1,169 @@ +mod claude_code; +mod cline; +mod codex; +mod copilot; +mod cursor; +mod droid; +mod gemini; +mod kimi; +mod opencode; +mod qwen; + use std::{ - env, fs, + fmt::Debug, + fs, path::{Path, PathBuf}, }; use anyhow::{Context, Result}; use clap::ValueEnum; -use serde::Deserialize; -use trodden_capture::claude_code::{self, Transcript}; +use trodden_capture::journal::Observation; use trodden_core::Trace; use trodden_redact::Redactor; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)] +use crate::connect::{Change, Program}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, ValueEnum)] pub enum Harness { - #[value(name = claude_code::HARNESS)] + #[value(name = "claude-code")] ClaudeCode, + #[value(name = "codex")] + Codex, + #[value(name = "gemini")] + Gemini, + #[value(name = "qwen")] + Qwen, + #[value(name = "copilot")] + Copilot, + #[value(name = "droid")] + Droid, + #[value(name = "cursor")] + Cursor, + #[value(name = "opencode")] + OpenCode, + #[value(name = "kilo")] + Kilo, + #[value(name = "kimi")] + Kimi, + #[value(name = "cline")] + Cline, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HookEvent { + pub name: String, + pub session: String, + pub cwd: PathBuf, + pub transcript: Option, + pub moment: Moment, + pub observed: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Moment { + SessionStart, + Prompt(String), + CommandFailed(String), + ToolDone, + TurnEnd { continued: bool }, + Compacting, + SessionEnd, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Reply<'a> { + Recall(&'a str), + Remind(&'a str), +} + +pub(crate) trait Agent: Debug + Sync { + fn title(&self) -> &'static str; + + fn journaled(&self) -> bool { + false + } + + fn history(&self) -> Result>; + + fn transcripts(&self, history: &Path) -> Result>; + + fn parse(&self, text: &str, redactor: &Redactor) -> Result<(Trace, Option)>; + + fn event(&self, payload: &str, redactor: &Redactor) -> Result>; + + fn render(&self, event: &HookEvent, reply: Reply<'_>) -> Option; + + fn detected(&self) -> bool; + + fn connect(&self, program: &Program) -> Result>; + + fn notice(&self) -> Option<&'static str> { + None + } + + fn disconnect(&self) -> Result>; + + fn connected(&self) -> Result; } impl Harness { pub const fn as_str(self) -> &'static str { match self { - Self::ClaudeCode => claude_code::HARNESS, + Self::ClaudeCode => "claude-code", + Self::Codex => "codex", + Self::Gemini => "gemini", + Self::Qwen => "qwen", + Self::Copilot => "copilot", + Self::Droid => "droid", + Self::Cursor => "cursor", + Self::OpenCode => "opencode", + Self::Kilo => "kilo", + Self::Kimi => "kimi", + Self::Cline => "cline", } } - pub fn from_name(name: &str) -> Option { + pub fn all() -> &'static [Self] { Self::value_variants() + } + + pub fn from_name(name: &str) -> Option { + Self::all() .iter() .copied() .find(|harness| harness.as_str() == name) } - pub fn history_dir(self) -> Result { + fn agent(self) -> &'static dyn Agent { match self { - Self::ClaudeCode => ClaudeCode::projects(), + Self::ClaudeCode => &claude_code::ClaudeCode, + Self::Codex => &codex::Codex, + Self::Gemini => &gemini::Gemini, + Self::Qwen => &qwen::Qwen, + Self::Copilot => &copilot::Copilot, + Self::Droid => &droid::Droid, + Self::Cursor => &cursor::Cursor, + Self::OpenCode => &opencode::OpenCode::OPENCODE, + Self::Kilo => &opencode::OpenCode::KILO, + Self::Kimi => &kimi::Kimi, + Self::Cline => &cline::Cline, } } + pub fn title(self) -> &'static str { + self.agent().title() + } + + pub fn journaled(self) -> bool { + self.agent().journaled() + } + + pub fn history_dir(self) -> Result> { + self.agent().history() + } + pub(crate) fn transcripts(self, history: &Path) -> Result> { - match self { - Self::ClaudeCode => ClaudeCode::transcripts(history), - } + self.agent().transcripts(history) } pub fn read(transcript: &Path) -> Result { @@ -53,73 +177,52 @@ impl Harness { } pub fn parse(self, text: &str, redactor: &Redactor) -> Result<(Trace, Option)> { - match self { - Self::ClaudeCode => Ok(( - Transcript::parse(text, redactor)?, - ClaudeCode::working_directory(text), - )), - } + self.agent().parse(text, redactor) } -} -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ClaudeCodeLine { - cwd: Option, - #[serde(default)] - is_sidechain: bool, -} + pub fn event(self, payload: &str, redactor: &Redactor) -> Result> { + self.agent().event(payload, redactor) + } -#[derive(Debug)] -struct ClaudeCode; - -impl ClaudeCode { - fn working_directory(text: &str) -> Option { - text.lines() - .filter_map(|line| serde_json::from_str::(line).ok()) - .filter(|line| !line.is_sidechain) - .find_map(|line| line.cwd) - } - - fn projects() -> Result { - Ok(env::home_dir() - .context("find the home directory")? - .join(".claude") - .join("projects")) - } - - fn transcripts(projects: &Path) -> Result> { - let mut transcripts = Vec::new(); - for project in - fs::read_dir(projects).with_context(|| format!("list {}", projects.display()))? - { - let project = project.context("read a project directory entry")?.path(); - let Ok(entries) = fs::read_dir(&project) else { - continue; - }; - for entry in entries.flatten() { - let path = entry.path(); - if path - .extension() - .is_some_and(|extension| extension == "jsonl") - { - transcripts.push(path); - } - } - } - transcripts.sort(); - Ok(transcripts) + pub fn render(self, event: &HookEvent, reply: Reply<'_>) -> Option { + self.agent().render(event, reply) + } + + pub fn detected(self) -> bool { + self.agent().detected() + } + + pub fn connect(self, program: &Program) -> Result> { + self.agent().connect(program) + } + + pub fn notice(self) -> Option<&'static str> { + self.agent().notice() + } + + pub fn disconnect(self) -> Result> { + self.agent().disconnect() + } + + pub fn connected(self) -> Result { + self.agent().connected() } } #[cfg(test)] mod tests { + use std::env; + use super::*; #[test] fn harness_names_round_trip() { - for harness in Harness::value_variants() { + for harness in Harness::all() { assert_eq!(Harness::from_name(harness.as_str()), Some(*harness)); + assert_eq!( + harness.to_possible_value().expect("visible").get_name(), + harness.as_str() + ); } assert_eq!(Harness::ClaudeCode.as_str(), "claude-code"); assert_eq!(Harness::from_name("future-agent"), None); @@ -143,28 +246,4 @@ mod tests { let text = "{\"cwd\":\"/home/dev/caf\u{e9}\"}\n"; assert_eq!(Harness::decode(text.as_bytes().to_vec()), text); } - - #[test] - fn invalid_bytes_only_spoil_their_own_line() { - let bytes = [ - b"{\"type\":\"user\",\"note\":\"caf\xe9\"}\n".as_slice(), - b"{\"cwd\":\"/home/dev/shop\"}\n", - b"{\"cwd\":\"/home/dev/caf\xc3", - ] - .concat(); - let text = Harness::decode(bytes); - let lines: Vec<&str> = text.lines().collect(); - assert_eq!( - lines, - [ - "{\"type\":\"user\",\"note\":\"caf\u{fffd}\"}", - "{\"cwd\":\"/home/dev/shop\"}", - "{\"cwd\":\"/home/dev/caf\u{fffd}", - ] - ); - assert_eq!( - ClaudeCode::working_directory(&text), - Some(PathBuf::from("/home/dev/shop")) - ); - } } diff --git a/crates/trodden/src/harness/claude_code.rs b/crates/trodden/src/harness/claude_code.rs new file mode 100644 index 0000000..1d548bd --- /dev/null +++ b/crates/trodden/src/harness/claude_code.rs @@ -0,0 +1,486 @@ +use std::{ + env, fs, + path::{Path, PathBuf}, +}; + +use anyhow::{Context, Result, bail}; +use serde::Deserialize; +use serde_json::{Value, json}; +use trodden_capture::claude_code::Transcript; +use trodden_core::Trace; +use trodden_redact::Redactor; + +use super::{Agent, Change, HookEvent, Moment, Reply}; +use crate::connect::{HookFile, Program}; + +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct ClaudeHookInput { + pub(crate) session_id: String, + #[serde(default)] + pub(crate) transcript_path: Option, + pub(crate) cwd: Option, + pub(crate) hook_event_name: String, + #[serde(default)] + pub(crate) prompt: Option, + #[serde(default)] + pub(crate) tool_name: Option, + #[serde(default)] + pub(crate) tool_response: Option, + #[serde(default)] + pub(crate) error: Option, + #[serde(default)] + pub(crate) is_interrupt: bool, + #[serde(default)] + pub(crate) stop_hook_active: bool, + #[serde(default)] + pub(crate) cursor_version: Option, +} + +impl ClaudeHookInput { + pub(crate) fn parse(payload: &str) -> Result> { + let input: Self = serde_json::from_str(payload).context("parse the hook payload")?; + if input.cursor_version.is_some() || env::var_os("CURSOR_VERSION").is_some() { + return Ok(None); + } + Ok(Some(input)) + } + + pub(crate) fn event(self, moment: Moment) -> Option { + let cwd = self.cwd.filter(|cwd| cwd.is_absolute())?; + Some(HookEvent { + name: self.hook_event_name, + session: self.session_id, + cwd, + transcript: self + .transcript_path + .filter(|path| !path.as_os_str().is_empty()), + moment, + observed: Vec::new(), + }) + } + + pub(crate) fn failure(&self, shell: &[&str]) -> Option { + let tool = self.tool_name.as_deref()?; + if !shell.contains(&tool) || self.is_interrupt { + return None; + } + self.error.clone() + } + + pub(crate) fn recall_context(event: &str, envelope: &str) -> String { + json!({ + "hookSpecificOutput": { + "hookEventName": event, + "additionalContext": envelope, + } + }) + .to_string() + } + + pub(crate) fn block(reason: &str) -> String { + json!({ "decision": "block", "reason": reason }).to_string() + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ClaudeCodeLine { + cwd: Option, + #[serde(default)] + is_sidechain: bool, +} + +#[derive(Debug)] +pub(crate) struct ClaudeCode; + +impl ClaudeCode { + fn working_directory(text: &str) -> Option { + text.lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .filter(|line| !line.is_sidechain) + .find_map(|line| line.cwd) + } + + const HARNESS: &str = "claude-code"; + + const TIMEOUT_SECONDS: u64 = 5; + + const EVENTS: &[(&str, Option<&str>)] = &[ + ("SessionStart", None), + ("UserPromptSubmit", None), + ("PostToolUseFailure", Some("Bash")), + ("Stop", None), + ("PreCompact", None), + ("SessionEnd", None), + ]; + + fn config() -> Result { + match env::var_os("CLAUDE_CONFIG_DIR").filter(|dir| !dir.is_empty()) { + Some(dir) => Ok(PathBuf::from(dir)), + None => Ok(env::home_dir() + .context("find the home directory")? + .join(".claude")), + } + } + + fn settings(config: &Path) -> PathBuf { + config.join("settings.json") + } + + // The plugin runs the same hooks; both at once would recall twice per prompt. + fn has_plugin(config: &Path) -> bool { + fs::read_to_string(config.join("plugins").join("installed_plugins.json")) + .is_ok_and(|text| text.contains("trodden@trodden")) + } + + fn connect_at(config: &Path, program: &Program) -> Result> { + if Self::has_plugin(config) { + bail!( + "the Trodden plugin for Claude Code is installed and already runs these hooks; \ + keep it, or run `claude plugin uninstall trodden@trodden` and connect again" + ); + } + let mut file = HookFile::load(Self::settings(config))?; + file.remove(&["hooks"], Self::HARNESS); + let command = program.hook(Self::HARNESS); + for (event, matcher) in Self::EVENTS { + let mut group = json!({"hooks": [{ + "type": "command", + "command": command, + "timeout": Self::TIMEOUT_SECONDS, + }]}); + if let Some(matcher) = matcher { + group["matcher"] = json!(matcher); + } + file.add(&["hooks"], event, group)?; + } + Ok(file.change()?.into_iter().collect()) + } + + fn disconnect_at(config: &Path) -> Result> { + let mut file = HookFile::load(Self::settings(config))?; + let removed = file.remove(&["hooks"], Self::HARNESS); + if !removed && Self::has_plugin(config) { + bail!( + "Trodden runs in Claude Code as a plugin; run `claude plugin uninstall trodden@trodden`" + ); + } + Ok(file.change()?.into_iter().collect()) + } + + fn connected_at(config: &Path) -> Result { + Ok(Self::has_plugin(config) + || HookFile::load(Self::settings(config))?.contains(&["hooks"], Self::HARNESS)) + } +} + +impl Agent for ClaudeCode { + fn title(&self) -> &'static str { + "Claude Code" + } + + fn history(&self) -> Result> { + Ok(Some(Self::config()?.join("projects"))) + } + + fn transcripts(&self, projects: &Path) -> Result> { + let mut transcripts = Vec::new(); + for project in + fs::read_dir(projects).with_context(|| format!("list {}", projects.display()))? + { + let project = project.context("read a project directory entry")?.path(); + let Ok(entries) = fs::read_dir(&project) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path + .extension() + .is_some_and(|extension| extension == "jsonl") + { + transcripts.push(path); + } + } + } + transcripts.sort(); + Ok(transcripts) + } + + fn parse(&self, text: &str, redactor: &Redactor) -> Result<(Trace, Option)> { + Ok(( + Transcript::parse(text, redactor)?, + Self::working_directory(text), + )) + } + + fn event(&self, payload: &str, _redactor: &Redactor) -> Result> { + let Some(input) = ClaudeHookInput::parse(payload)? else { + return Ok(None); + }; + let moment = match input.hook_event_name.as_str() { + "SessionStart" => Moment::SessionStart, + "UserPromptSubmit" => match &input.prompt { + Some(prompt) => Moment::Prompt(prompt.clone()), + None => return Ok(None), + }, + "PostToolUseFailure" => match input.failure(&["Bash"]) { + Some(error) => Moment::CommandFailed(error), + None => return Ok(None), + }, + "Stop" => Moment::TurnEnd { + continued: input.stop_hook_active, + }, + "PreCompact" => Moment::Compacting, + "SessionEnd" => Moment::SessionEnd, + _ => return Ok(None), + }; + Ok(input.event(moment)) + } + + fn render(&self, event: &HookEvent, reply: Reply<'_>) -> Option { + match (&event.moment, reply) { + (Moment::Prompt(_), Reply::Recall(envelope)) => Some(envelope.to_owned()), + (Moment::CommandFailed(_), Reply::Recall(envelope)) => Some( + ClaudeHookInput::recall_context("PostToolUseFailure", envelope), + ), + (Moment::TurnEnd { .. }, Reply::Remind(reason)) => Some(ClaudeHookInput::block(reason)), + _ => None, + } + } + + fn detected(&self) -> bool { + Self::config().is_ok_and(|config| config.is_dir()) + } + + fn connect(&self, program: &Program) -> Result> { + Self::connect_at(&Self::config()?, program) + } + + fn disconnect(&self) -> Result> { + Self::disconnect_at(&Self::config()?) + } + + fn connected(&self) -> Result { + Self::connected_at(&Self::config()?) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Debug)] + struct Payload; + + impl Payload { + fn event(payload: Value) -> Option { + ClaudeCode + .event(&payload.to_string(), &Redactor::with_home("/home/dev")) + .expect("payload parses") + } + } + + #[test] + fn invalid_bytes_only_spoil_their_own_line() { + let bytes = [ + b"{\"type\":\"user\",\"note\":\"caf\xe9\"}\n".as_slice(), + b"{\"cwd\":\"/home/dev/shop\"}\n", + b"{\"cwd\":\"/home/dev/caf\xc3", + ] + .concat(); + let text = String::from_utf8_lossy(&bytes).into_owned(); + + assert_eq!( + ClaudeCode::working_directory(&text), + Some(PathBuf::from("/home/dev/shop")) + ); + } + + #[test] + fn hook_events_map_to_moments() { + let base = + json!({"session_id": "s", "cwd": "/home/dev/shop", "transcript_path": "/t.jsonl"}); + let with = |extra: Value| { + let mut payload = base.clone(); + payload + .as_object_mut() + .expect("object") + .extend(extra.as_object().expect("object").clone()); + Payload::event(payload).map(|event| event.moment) + }; + + assert_eq!( + with(json!({"hook_event_name": "UserPromptSubmit", "prompt": "Fix paging"})), + Some(Moment::Prompt("Fix paging".to_owned())) + ); + assert_eq!( + with( + json!({"hook_event_name": "PostToolUseFailure", "tool_name": "Bash", "error": "boom"}) + ), + Some(Moment::CommandFailed("boom".to_owned())) + ); + assert_eq!( + with( + json!({"hook_event_name": "PostToolUseFailure", "tool_name": "Bash", "error": "x", "is_interrupt": true}) + ), + None + ); + assert_eq!( + with( + json!({"hook_event_name": "PostToolUseFailure", "tool_name": "Read", "error": "x"}) + ), + None + ); + assert_eq!( + with(json!({"hook_event_name": "Stop", "stop_hook_active": true})), + Some(Moment::TurnEnd { continued: true }) + ); + assert_eq!( + with(json!({"hook_event_name": "SessionEnd"})), + Some(Moment::SessionEnd) + ); + assert_eq!(with(json!({"hook_event_name": "Notification"})), None); + } + + #[test] + fn foreign_payloads_are_ignored() { + assert_eq!( + Payload::event( + json!({"session_id": "s", "hook_event_name": "Stop", "cwd": "/w", "cursor_version": "3.23"}) + ), + None + ); + assert_eq!( + Payload::event(json!({"session_id": "s", "hook_event_name": "Stop"})), + None + ); + let empty = Payload::event(json!({"session_id": "s", "hook_event_name": "Stop", "cwd": "/w", "transcript_path": ""})) + .expect("event"); + assert_eq!(empty.transcript, None); + } + + #[test] + fn replies_take_the_shape_each_event_needs() { + let event = |moment| HookEvent { + name: String::new(), + session: "s".to_owned(), + cwd: PathBuf::from("/w"), + transcript: None, + moment, + observed: Vec::new(), + }; + + assert_eq!( + ClaudeCode.render( + &event(Moment::Prompt("p".to_owned())), + Reply::Recall("") + ), + Some("".to_owned()) + ); + let failure = ClaudeCode + .render( + &event(Moment::CommandFailed("e".to_owned())), + Reply::Recall(""), + ) + .expect("failure recall renders"); + let failure: Value = serde_json::from_str(&failure).expect("JSON"); + assert_eq!( + failure["hookSpecificOutput"]["hookEventName"], + "PostToolUseFailure" + ); + assert_eq!(failure["hookSpecificOutput"]["additionalContext"], ""); + assert_eq!( + ClaudeCode.render( + &event(Moment::TurnEnd { continued: false }), + Reply::Remind("run it") + ), + Some(r#"{"decision":"block","reason":"run it"}"#.to_owned()) + ); + } + + #[derive(Debug)] + struct Config { + dir: PathBuf, + } + + impl Config { + fn new(name: &str) -> Self { + let dir = env::temp_dir().join(format!("trodden-claude-{name}-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("scratch dir is writable"); + Self { dir } + } + + fn apply(changes: Vec) { + for change in changes { + change.apply().expect("change applies"); + } + } + + fn settings(&self) -> Value { + serde_json::from_str( + &fs::read_to_string(self.dir.join("settings.json")).expect("settings read"), + ) + .expect("settings are JSON") + } + } + + impl Drop for Config { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.dir); + } + } + + #[test] + fn connect_writes_the_hooks_into_settings_and_round_trips() { + let config = Config::new("connect"); + fs::write( + config.dir.join("settings.json"), + r#"{"model":"opus","hooks":{"Stop":[{"hooks":[{"type":"command","command":"afplay done.aiff"}]}]}}"#, + ) + .expect("settings are writable"); + let program = Program::at("/home/dev/.cargo/bin/trodden"); + + Config::apply(ClaudeCode::connect_at(&config.dir, &program).expect("connect")); + + let settings = config.settings(); + assert!(ClaudeCode::connected_at(&config.dir).expect("settings load")); + assert_eq!(settings["model"], "opus"); + assert_eq!( + settings["hooks"]["Stop"][0]["hooks"][0]["command"], + "afplay done.aiff" + ); + assert_eq!( + settings["hooks"]["PostToolUseFailure"], + json!([{"matcher": "Bash", "hooks": [{"type": "command", "command": "/home/dev/.cargo/bin/trodden hook claude-code", "timeout": 5}]}]) + ); + assert!( + ClaudeCode::connect_at(&config.dir, &program) + .expect("connect again") + .is_empty() + ); + + Config::apply(ClaudeCode::disconnect_at(&config.dir).expect("disconnect")); + + assert!(!ClaudeCode::connected_at(&config.dir).expect("settings load")); + assert_eq!( + config.settings(), + json!({"model": "opus", "hooks": {"Stop": [{"hooks": [{"type": "command", "command": "afplay done.aiff"}]}]}}) + ); + } + + #[test] + fn an_installed_plugin_is_not_doubled() { + let config = Config::new("plugin"); + fs::create_dir_all(config.dir.join("plugins")).expect("plugins dir is writable"); + fs::write( + config.dir.join("plugins").join("installed_plugins.json"), + r#"{"plugins":{"trodden@trodden":[{"scope":"user"}]}}"#, + ) + .expect("plugin list is writable"); + + assert!(ClaudeCode::connected_at(&config.dir).expect("settings load")); + assert!(ClaudeCode::connect_at(&config.dir, &Program::at("/t")).is_err()); + assert!(ClaudeCode::disconnect_at(&config.dir).is_err()); + } +} diff --git a/crates/trodden/src/harness/cline.rs b/crates/trodden/src/harness/cline.rs new file mode 100644 index 0000000..c004151 --- /dev/null +++ b/crates/trodden/src/harness/cline.rs @@ -0,0 +1,956 @@ +use std::{ + collections::HashMap, + env, fs, + path::{Path, PathBuf}, +}; + +use anyhow::{Context, Result, bail}; +use jiff::Timestamp; +use serde::Deserialize; +use serde_json::{Value, json}; +use trodden_capture::journal::{Edit, Journal, Observation, Observer, Ran}; +use trodden_core::{Trace, trace::ToolAction}; +use trodden_redact::Redactor; + +use super::{Agent, Change, HookEvent, Moment, Reply}; +use crate::connect::{HookFile, Program}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ClineHookInput { + hook_name: String, + #[serde(default)] + task_id: Option, + #[serde(default)] + session_context: Option, + #[serde(default)] + workspace_roots: Vec, + #[serde(default)] + workspace_info: Option, + #[serde(default)] + timestamp: Option, + #[serde(default)] + model: Option, + #[serde(default, rename = "parent_agent_id")] + parent_agent_id: Option, + #[serde(default)] + user_prompt_submit: Option, + #[serde(default)] + post_tool_use: Option, + #[serde(default, rename = "tool_result")] + tool_result: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SessionContext { + #[serde(default)] + root_session_id: Option, +} + +#[derive(Debug, Deserialize)] +struct PromptSubmit { + #[serde(default)] + prompt: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PostToolUse { + tool_name: String, + #[serde(default)] + parameters: HashMap, + #[serde(default)] + result: Option, + #[serde(default)] + success: Option, + #[serde(default)] + execution_time_ms: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ToolResult { + #[serde(default)] + name: Option, + #[serde(default)] + input: Option, + #[serde(default)] + output: Option, + #[serde(default)] + error: Option, + #[serde(default)] + duration_ms: Option, +} + +#[derive(Debug)] +struct ToolUse { + name: String, + input: HashMap, + output: String, + succeeded: bool, + duration_ms: Option, +} + +impl ToolUse { + const DENIED: &str = "The user denied this operation"; + + fn of(input: &ClineHookInput) -> Option { + let post = input.post_tool_use.as_ref(); + let raw = input.tool_result.as_ref(); + let name = post + .map(|post| post.tool_name.clone()) + .or_else(|| raw.and_then(|raw| raw.name.clone()))?; + let mut arguments: HashMap = + post.map(|post| post.parameters.clone()).unwrap_or_default(); + if let Some(Value::Object(fields)) = raw.and_then(|raw| raw.input.as_ref()) { + arguments.extend(fields.clone()); + } + let error = raw + .and_then(|raw| raw.error.as_ref()) + .map(Self::text) + .filter(|error| !error.is_empty()); + let output = post + .and_then(|post| post.result.as_ref()) + .or_else(|| raw.and_then(|raw| raw.output.as_ref())) + .map(Self::text) + .unwrap_or_default(); + let output = match &error { + Some(error) if !output.contains(error.as_str()) => { + format!("{output}\n{error}").trim().to_owned() + } + _ => output, + }; + let succeeded = post + .and_then(|post| post.success) + .unwrap_or(error.is_none()); + Some(Self { + name, + input: arguments, + output, + succeeded, + duration_ms: post + .and_then(|post| post.execution_time_ms) + .or_else(|| raw.and_then(|raw| raw.duration_ms)), + }) + } + + fn text(value: &Value) -> String { + match value { + Value::String(text) => text.clone(), + Value::Null => String::new(), + other => other.to_string(), + } + } + + fn param(&self, key: &str) -> Option { + match self.input.get(key)? { + Value::String(text) => Some(text.clone()), + Value::Null => None, + other => Some(other.to_string()), + } + } + + fn command(&self) -> Option { + if let Some(command) = self.param("command") { + return Some(command); + } + let commands = match self.input.get("commands")? { + Value::String(text) => { + serde_json::from_str(text).unwrap_or_else(|_| Value::String(text.clone())) + } + other => other.clone(), + }; + match commands { + Value::String(text) => Some(text), + Value::Array(items) => { + let commands: Vec<&str> = items.iter().filter_map(Value::as_str).collect(); + (!commands.is_empty()).then(|| commands.join(" && ")) + } + _ => None, + } + } + + fn exit_code(&self) -> Option { + let lowered = self.output.to_lowercase(); + let (_, rest) = lowered.rsplit_once("exit code")?; + let digits: String = rest + .trim_start_matches([' ', ':']) + .chars() + .take_while(|c| c.is_ascii_digit() || *c == '-') + .collect(); + digits.parse().ok() + } + + fn denied(&self) -> bool { + self.output.contains(Self::DENIED) + } + + fn replacements(diff: &str) -> (String, String) { + let (mut old, mut new) = (String::new(), String::new()); + let mut side = None; + for line in diff.lines() { + let marker = line.trim(); + if marker.ends_with("SEARCH") && marker.starts_with(['-', '<']) { + side = Some(false); + } else if marker.starts_with("=======") && side == Some(false) { + side = Some(true); + } else if marker.ends_with("REPLACE") && marker.starts_with(['+', '>']) { + side = None; + } else if let Some(replacing) = side { + let target = if replacing { &mut new } else { &mut old }; + target.push_str(line); + target.push('\n'); + } + } + (old, new) + } + + fn patch_path(patch: &str) -> Option<&str> { + patch.lines().find_map(|line| { + ["*** Update File: ", "*** Add File: ", "*** Delete File: "] + .iter() + .find_map(|prefix| line.strip_prefix(prefix)) + .map(str::trim) + }) + } +} + +#[derive(Debug)] +pub(crate) struct Cline; + +impl Cline { + const HARNESS: &str = "cline"; + + const SHELLS: &[&str] = &["execute_command", "run_commands"]; + + const BOOKKEEPING_TOOLS: &[&str] = &[ + "attempt_completion", + "ask_followup_question", + "plan_mode_respond", + "act_mode_respond", + "focus_chain", + "todo", + "new_task", + "condense", + "summarize_task", + "report_bug", + "load_mcp_documentation", + "generate_explanation", + ]; + + const EVENTS: &[&str] = &[ + "TaskStart", + "TaskResume", + "UserPromptSubmit", + "PostToolUse", + "TaskComplete", + "TaskCancel", + "SessionShutdown", + ]; + + pub(crate) const MANUAL_STEP: &str = "In VS Code or JetBrains, turn on Cline's \"Enable Hooks\" setting (the Cline CLI needs nothing)."; + + fn config() -> Result { + Ok(env::home_dir() + .context("find the home directory")? + .join(".cline")) + } + + fn hooks_dir() -> Result { + match env::var_os("CLINE_HOOKS_DIR").filter(|dir| !dir.is_empty()) { + Some(dir) => Ok(PathBuf::from(dir)), + None => Ok(Self::config()?.join("hooks")), + } + } + + fn script(program: &Program) -> String { + format!( + "#!/bin/sh\n\ + # Installed by `trodden connect cline`; `trodden disconnect cline` removes it.\n\ + reply=$({})\n\ + if [ -n \"$reply\" ]; then\n \ + printf '%s\\n' \"$reply\"\n\ + else\n \ + printf '%s\\n' '{{\"cancel\":false}}'\n\ + fi\n", + program.hook(Self::HARNESS) + ) + } + + fn is_ours(path: &Path) -> bool { + fs::read_to_string(path).is_ok_and(|text| HookFile::is_ours(&text, Self::HARNESS)) + } + + fn others(dir: &Path, event: &str) -> Vec { + let Ok(entries) = fs::read_dir(dir) else { + return Vec::new(); + }; + entries + .flatten() + .map(|entry| entry.path()) + .filter(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .and_then(|name| name.split('.').next()) + .is_some_and(|stem| stem.eq_ignore_ascii_case(event)) + }) + .filter(|path| !Self::is_ours(path)) + .collect() + } + + fn connect_at(dir: &Path, program: &Program) -> Result> { + if cfg!(windows) { + bail!("Cline runs hook scripts only on macOS and Linux"); + } + let mut changes = Vec::new(); + for event in Self::EVENTS { + if let Some(other) = Self::others(dir, event).first() { + bail!( + "{} is a {event} hook Trodden did not write; Cline runs one hook per event, so merge Trodden's by hand: pipe the payload to `{}`", + other.display(), + program.hook(Self::HARNESS) + ); + } + let change = Change::write(dir.join(event), Self::script(program)).executable(); + if change.is_needed() || !Self::is_executable(&change.path) { + changes.push(change); + } + } + Ok(changes) + } + + fn is_executable(path: &Path) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::metadata(path).is_ok_and(|metadata| metadata.permissions().mode() & 0o111 != 0) + } + #[cfg(not(unix))] + { + path.exists() + } + } + + fn disconnect_at(dir: &Path) -> Vec { + Self::EVENTS + .iter() + .map(|event| dir.join(event)) + .filter(|path| Self::is_ours(path)) + .map(Change::remove) + .collect() + } + + fn connected_at(dir: &Path) -> bool { + Self::EVENTS + .iter() + .all(|event| Self::is_ours(&dir.join(event))) + } + + fn at(timestamp: Option<&Value>) -> Timestamp { + let millis = match timestamp { + Some(Value::Number(number)) => number.as_i64(), + Some(Value::String(text)) if text.bytes().all(|b| b.is_ascii_digit()) => { + text.parse().ok() + } + Some(Value::String(text)) => return text.parse().unwrap_or_else(|_| Timestamp::now()), + _ => None, + }; + millis + .and_then(|millis| Timestamp::from_millisecond(millis).ok()) + .unwrap_or_else(Timestamp::now) + } + + fn observe(observer: &Observer<'_>, tool: &ToolUse) -> Option { + let name = tool.name.as_str(); + let succeeded = tool.succeeded && !tool.denied(); + let path = tool.param("path").or_else(|| tool.param("absolutePath")); + let observation = match name { + _ if Self::SHELLS.contains(&name) => { + let command = tool.command()?; + let exit_code = tool.exit_code(); + observer.command( + name, + &command, + Ran { + output: &tool.output, + exit_code, + failed: !tool.succeeded || exit_code.is_some_and(|code| code != 0), + interrupted: tool.denied(), + duration_ms: tool.duration_ms, + }, + ) + } + "write_to_file" | "create_file" => { + let content = tool.param("content").unwrap_or_default(); + observer.edit(name, &path?, Edit::Created { content: &content }, succeeded) + } + "replace_in_file" => { + let (old, new) = ToolUse::replacements(&tool.param("diff").unwrap_or_default()); + observer.edit( + name, + &path?, + Edit::Replaced { + old: &old, + new: &new, + }, + succeeded, + ) + } + "editor" => { + let text = |key: &str| tool.param(key).unwrap_or_default(); + match tool.param("command").as_deref() { + Some("view") => observer.read(name, &path?), + Some("create") => observer.edit( + name, + &path?, + Edit::Created { + content: &text("file_text"), + }, + succeeded, + ), + Some("insert") => { + let added: String = text("new_str") + .lines() + .map(|line| format!("+{line}\n")) + .collect(); + observer.edit(name, &path?, Edit::Patched { diff: &added }, succeeded) + } + _ => observer.edit( + name, + &path?, + Edit::Replaced { + old: &text("old_str"), + new: &text("new_str"), + }, + succeeded, + ), + } + } + "apply_patch" => { + let patch = tool + .param("input") + .or_else(|| tool.param("patch")) + .unwrap_or_default(); + let path = ToolUse::patch_path(&patch).map(str::to_owned).or(path)?; + observer.edit(name, &path, Edit::Patched { diff: &patch }, succeeded) + } + "delete_file" => observer.edit(name, &path?, Edit::Unknown, succeeded), + "read_file" | "read_files" => { + let path = path.or_else(|| tool.param("paths"))?; + observer.read(name, &path) + } + "search_files" | "search_codebase" => observer.search( + name, + tool.param("regex") + .or_else(|| tool.param("query")) + .as_deref(), + path.as_deref(), + ), + "list_files" | "list_code_definition_names" => { + observer.search(name, None, path.as_deref()) + } + "web_fetch" | "fetch" => observer.fetch(name, tool.param("url").as_deref(), None), + "web_search" => observer.fetch(name, None, tool.param("query").as_deref()), + "use_subagents" | "spawn_agent" => observer.other(name, ToolAction::Delegate), + _ if Self::BOOKKEEPING_TOOLS.contains(&name) => return None, + _ => observer.other(name, ToolAction::Other), + }; + Some(observation) + } + + fn event_from(input: ClineHookInput, redactor: &Redactor) -> Option { + if input.parent_agent_id.is_some() { + return None; + } + let session = input + .session_context + .as_ref() + .and_then(|context| context.root_session_id.clone()) + .or_else(|| input.task_id.clone()) + .filter(|session| !session.is_empty())?; + let cwd = input + .workspace_roots + .first() + .cloned() + .or_else(|| { + input + .workspace_info + .as_ref() + .and_then(|info| info.get("rootPath")) + .and_then(Value::as_str) + .map(PathBuf::from) + }) + .filter(|cwd| cwd.is_absolute())?; + let model = match &input.model { + Some(Value::String(model)) => Some(model.clone()), + Some(model) => model + .get("slug") + .or_else(|| model.get("id")) + .and_then(Value::as_str) + .map(str::to_owned), + None => None, + }; + let cwd_text = cwd.to_string_lossy().into_owned(); + let observer = Observer { + session: &session, + cwd: &cwd_text, + at: Self::at(input.timestamp.as_ref()), + model: model.as_deref(), + redactor, + }; + let mut observed = Vec::new(); + let moment = match input.hook_name.as_str() { + "UserPromptSubmit" | "prompt_submit" => { + let prompt = input.user_prompt_submit.as_ref()?.prompt.clone(); + observed.push(observer.prompt(&prompt)); + Moment::Prompt(prompt) + } + "PostToolUse" | "tool_result" => { + let tool = ToolUse::of(&input)?; + observed.extend(Self::observe(&observer, &tool)); + let failed = Self::SHELLS.contains(&tool.name.as_str()) + && !tool.denied() + && (!tool.succeeded || tool.exit_code().is_some_and(|code| code != 0)); + if failed { + Moment::CommandFailed(tool.output) + } else { + Moment::ToolDone + } + } + "TaskStart" | "TaskResume" | "agent_start" | "agent_resume" => Moment::SessionStart, + "TaskComplete" | "agent_end" | "TaskCancel" | "agent_abort" | "TaskError" + | "agent_error" => Moment::TurnEnd { continued: false }, + "SessionShutdown" | "session_shutdown" => Moment::SessionEnd, + _ => return None, + }; + Some(HookEvent { + name: input.hook_name, + session, + cwd, + transcript: None, + moment, + observed, + }) + } +} + +impl Agent for Cline { + fn title(&self) -> &'static str { + "Cline" + } + + fn journaled(&self) -> bool { + true + } + + fn history(&self) -> Result> { + Ok(None) + } + + fn transcripts(&self, _history: &Path) -> Result> { + Ok(Vec::new()) + } + + fn parse(&self, text: &str, redactor: &Redactor) -> Result<(Trace, Option)> { + Ok(( + Journal::parse(text, Self::HARNESS, redactor)?, + Journal::working_directory(text).map(PathBuf::from), + )) + } + + fn event(&self, payload: &str, redactor: &Redactor) -> Result> { + let input: ClineHookInput = + serde_json::from_str(payload).context("parse the hook payload")?; + Ok(Self::event_from(input, redactor)) + } + + fn render(&self, event: &HookEvent, reply: Reply<'_>) -> Option { + match (&event.moment, reply) { + (Moment::Prompt(_) | Moment::CommandFailed(_), Reply::Recall(envelope)) => { + Some(json!({ "cancel": false, "contextModification": envelope }).to_string()) + } + _ => None, + } + } + + fn detected(&self) -> bool { + Self::config().is_ok_and(|config| config.is_dir()) + } + + fn notice(&self) -> Option<&'static str> { + Some(Self::MANUAL_STEP) + } + + fn connect(&self, program: &Program) -> Result> { + Self::connect_at(&Self::hooks_dir()?, program) + } + + fn disconnect(&self) -> Result> { + Ok(Self::disconnect_at(&Self::hooks_dir()?)) + } + + fn connected(&self) -> Result { + Ok(Self::connected_at(&Self::hooks_dir()?)) + } +} + +#[cfg(test)] +mod tests { + use std::process; + + use trodden_capture::journal::Observed; + use trodden_core::trace::{EventKind, ToolOutcome}; + + use super::*; + + const CWD: &str = "/home/dev/shop"; + + #[derive(Debug)] + struct Payload; + + impl Payload { + fn vscode(hook: &str, extra: Value) -> Value { + let mut payload = json!({ + "clineVersion": "4.1.23", + "hookName": hook, + "timestamp": "1791540000000", + "workspaceRoots": [CWD], + "userId": "u1", + "taskId": "1791539990000", + "model": {"provider": "anthropic", "slug": "claude-sonnet-4-5"}, + }); + payload + .as_object_mut() + .expect("object") + .extend(extra.as_object().expect("object").clone()); + payload + } + + fn event(payload: &Value) -> Option { + Cline + .event(&payload.to_string(), &Redactor::with_home("/home/dev")) + .expect("payload parses") + } + + fn tool(name: &str, parameters: Value, result: &str, success: bool) -> Option { + Self::event(&Self::vscode( + "PostToolUse", + json!({"postToolUse": {"toolName": name, "parameters": parameters, "result": result, + "success": success, "executionTimeMs": 900}}), + )) + } + + fn journal(events: &[HookEvent]) -> Trace { + let text: String = events + .iter() + .flat_map(|event| &event.observed) + .map(|observation| observation.to_line().expect("line encodes")) + .collect(); + Cline + .parse(&text, &Redactor::with_home("/home/dev")) + .expect("journal parses") + .0 + } + } + + #[derive(Debug)] + struct Scratch { + dir: PathBuf, + } + + impl Scratch { + fn new(name: &str) -> Self { + let dir = env::temp_dir().join(format!("trodden-cline-{name}-{}", process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("scratch dir is writable"); + Self { dir } + } + + fn apply(changes: Vec) { + for change in changes { + change.apply().expect("change applies"); + } + } + } + + impl Drop for Scratch { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.dir); + } + } + + #[test] + fn prompts_are_recalled_and_journaled() { + let event = Payload::event(&Payload::vscode( + "UserPromptSubmit", + json!({"userPromptSubmit": {"prompt": "Fix the paging bug in src/paginate.js", "attachments": []}}), + )) + .expect("event"); + + assert_eq!(event.session, "1791539990000"); + assert_eq!(event.cwd, PathBuf::from(CWD)); + assert_eq!( + event.moment, + Moment::Prompt("Fix the paging bug in src/paginate.js".to_owned()) + ); + assert_eq!(event.observed.len(), 1); + assert_eq!( + event.observed[0].model.as_deref(), + Some("claude-sonnet-4-5") + ); + assert_eq!( + event.observed[0].at, + Timestamp::from_millisecond(1_791_540_000_000).expect("valid") + ); + assert!( + matches!(&event.observed[0].observed, Observed::Prompt { text } if text.starts_with("Fix the paging")) + ); + } + + #[test] + fn shell_failures_are_recalled_and_every_call_journaled() { + let failed = Payload::tool( + "execute_command", + json!({"command": "npm test", "requires_approval": "false"}), + "Command failed with exit code 1.\nOutput:\nError: Cannot find module 'left-pad'", + true, + ) + .expect("event"); + let passed = Payload::tool( + "execute_command", + json!({"command": "npm test"}), + "1 passing", + true, + ) + .expect("event"); + let denied = Payload::tool( + "execute_command", + json!({"command": "rm -rf dist"}), + "The user denied this operation.", + false, + ) + .expect("event"); + + assert!( + matches!(&failed.moment, Moment::CommandFailed(output) if output.contains("left-pad")) + ); + assert_eq!(passed.moment, Moment::ToolDone); + assert_eq!(denied.moment, Moment::ToolDone); + let trace = Payload::journal(&[failed, passed, denied]); + let outcomes: Vec = trace + .events + .into_iter() + .filter_map(|event| match event.kind { + EventKind::ToolCall(call) => Some(call.outcome), + _ => None, + }) + .collect(); + assert_eq!( + outcomes, + [ + ToolOutcome::Failed { exit_code: Some(1) }, + ToolOutcome::Succeeded, + ToolOutcome::Interrupted + ] + ); + } + + #[test] + fn sdk_tool_results_are_read_too() { + let event = Payload::event(&json!({ + "clineVersion": "3.0.70", "hookName": "tool_result", "timestamp": "2026-10-09T10:00:09.000Z", + "taskId": "run_1", "sessionContext": {"rootSessionId": "1791540000000_k3x9q"}, + "workspaceRoots": [CWD], "userId": "me", "agent_id": "lead", "parent_agent_id": null, + "tool_result": {"id": "call_1", "name": "run_commands", "input": {"commands": ["npm test"]}, + "output": "1 failing", "error": "Error: expected 2 to equal 3", "durationMs": 5120}, + "postToolUse": {"toolName": "run_commands", "parameters": {"commands": "[\"npm test\"]"}, + "result": "1 failing", "success": false, "executionTimeMs": 5120} + })) + .expect("event"); + + assert_eq!(event.session, "1791540000000_k3x9q"); + assert!( + matches!(&event.moment, Moment::CommandFailed(output) if output.contains("expected 2 to equal 3")) + ); + assert!(matches!(&event.observed[0].observed, + Observed::Call { args, duration_ms: Some(5120), .. } if args.command.as_deref() == Some("npm test"))); + let subagent = Payload::event( + &json!({"hookName": "tool_result", "taskId": "t", "workspaceRoots": [CWD], + "parent_agent_id": "lead", "tool_result": {"name": "run_commands", "input": {"commands": ["ls"]}}}), + ); + assert_eq!(subagent, None); + } + + #[test] + fn edits_reads_and_searches_are_journaled() { + let events = [ + Payload::tool("read_file", json!({"path": "src/paginate.js"}), "export function paginate", true), + Payload::tool( + "replace_in_file", + json!({"path": "src/paginate.js", "diff": "------- SEARCH\n const end = start + perPage + 1;\n=======\n const end = start + perPage;\n+++++++ REPLACE\n"}), + "The content was successfully saved to src/paginate.js.", + true, + ), + Payload::tool("write_to_file", json!({"path": "test/paging.test.js", "content": "a\nb\n"}), "saved", true), + Payload::tool("search_files", json!({"path": "src", "regex": "paginate"}), "Found 2 results", true), + Payload::tool("attempt_completion", json!({"result": "Done"}), "", true), + ] + .map(|event| event.expect("event")); + + assert!(events.iter().all(|event| event.moment == Moment::ToolDone)); + assert!( + events[4].observed.is_empty(), + "bookkeeping is not journaled" + ); + let calls: Vec = Payload::journal(&events) + .events + .into_iter() + .filter_map(|event| match event.kind { + EventKind::ToolCall(call) => Some(format!( + "{:?} {} {:?}", + call.action, + call.args.path.unwrap_or_default(), + call.changes + .iter() + .map(|change| (change.lines_added, change.lines_removed)) + .collect::>() + )), + _ => None, + }) + .collect(); + assert_eq!( + calls, + [ + "Read src/paginate.js []", + "Edit src/paginate.js [(1, 1)]", + "Edit test/paging.test.js [(2, 0)]", + "Search src []", + ] + ); + } + + #[test] + fn lifecycle_events_map_to_moments() { + let moment = |hook: &str| { + Payload::event(&Payload::vscode(hook, json!({}))).map(|event| event.moment) + }; + + assert_eq!(moment("TaskStart"), Some(Moment::SessionStart)); + assert_eq!(moment("agent_resume"), Some(Moment::SessionStart)); + assert_eq!( + moment("TaskComplete"), + Some(Moment::TurnEnd { continued: false }) + ); + assert_eq!( + moment("TaskCancel"), + Some(Moment::TurnEnd { continued: false }) + ); + assert_eq!(moment("session_shutdown"), Some(Moment::SessionEnd)); + assert_eq!(moment("PreToolUse"), None); + assert_eq!( + Payload::event( + &json!({"hookName": "TaskStart", "taskId": "t", "workspaceRoots": ["shop"]}) + ), + None + ); + assert!(Cline.event("[]", &Redactor::new()).is_err()); + } + + #[test] + fn replies_inject_context_and_never_continue() { + let event = |moment| HookEvent { + name: String::new(), + session: "s".to_owned(), + cwd: PathBuf::from(CWD), + transcript: None, + moment, + observed: Vec::new(), + }; + + assert_eq!( + Cline.render( + &event(Moment::Prompt("p".to_owned())), + Reply::Recall("") + ), + Some(r#"{"cancel":false,"contextModification":""}"#.to_owned()) + ); + assert_eq!( + Cline.render( + &event(Moment::CommandFailed("e".to_owned())), + Reply::Recall("") + ), + Some(r#"{"cancel":false,"contextModification":""}"#.to_owned()) + ); + assert_eq!( + Cline.render( + &event(Moment::TurnEnd { continued: false }), + Reply::Remind("run it") + ), + None + ); + } + + #[test] + fn connect_writes_one_script_per_event_and_round_trips() { + let scratch = Scratch::new("connect"); + fs::write( + scratch.dir.join("PreToolUse.py"), + "#!/usr/bin/env python3\n", + ) + .expect("written"); + let program = Program::at("/home/dev/.cargo/bin/trodden"); + + assert!(!Cline::connected_at(&scratch.dir)); + Scratch::apply(Cline::connect_at(&scratch.dir, &program).expect("connect")); + + assert!(Cline::connected_at(&scratch.dir)); + let script = fs::read_to_string(scratch.dir.join("PostToolUse")).expect("script"); + assert!(script.starts_with("#!/bin/sh\n"), "{script}"); + assert!( + script.contains("reply=$(/home/dev/.cargo/bin/trodden hook cline)"), + "{script}" + ); + assert!( + script.contains(r#"printf '%s\n' '{"cancel":false}'"#), + "{script}" + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = fs::metadata(scratch.dir.join("PostToolUse")) + .expect("stat") + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o755); + } + assert!( + Cline::connect_at(&scratch.dir, &program) + .expect("connect") + .is_empty() + ); + + Scratch::apply(Cline::disconnect_at(&scratch.dir)); + assert!(!Cline::connected_at(&scratch.dir)); + assert!(!scratch.dir.join("PostToolUse").exists()); + assert!( + scratch.dir.join("PreToolUse.py").exists(), + "the user's hooks stay" + ); + assert!(!Cline::MANUAL_STEP.is_empty()); + } + + #[test] + fn a_users_own_hook_for_the_same_event_is_not_overwritten() { + let scratch = Scratch::new("conflict"); + fs::write(scratch.dir.join("TaskComplete.sh"), "#!/bin/sh\nsay done\n").expect("written"); + + let error = Cline::connect_at(&scratch.dir, &Program::at("/opt/trodden")) + .expect_err("a foreign hook blocks connect"); + + assert!( + format!("{error:#}").contains("TaskComplete.sh"), + "{error:#}" + ); + } +} diff --git a/crates/trodden/src/harness/codex.rs b/crates/trodden/src/harness/codex.rs new file mode 100644 index 0000000..1d1c796 --- /dev/null +++ b/crates/trodden/src/harness/codex.rs @@ -0,0 +1,562 @@ +use std::{ + env, fs, + io::{BufRead, BufReader}, + path::{Path, PathBuf}, +}; + +use anyhow::{Context, Result}; +use serde::Deserialize; +use serde_json::{Value, json}; +use trodden_capture::codex::Rollout; +use trodden_core::Trace; +use trodden_redact::Redactor; + +use super::{Agent, Change, HookEvent, Moment, Reply, claude_code::ClaudeHookInput}; +use crate::connect::{HookFile, Program}; + +#[derive(Debug, Deserialize)] +struct CodexHookInput { + session_id: String, + #[serde(default)] + transcript_path: Option, + #[serde(default)] + cwd: Option, + hook_event_name: String, + #[serde(default)] + agent_id: Option, + #[serde(default)] + prompt: Option, + #[serde(default)] + tool_name: Option, + #[serde(default)] + tool_input: Option, + #[serde(default)] + tool_response: Option, + #[serde(default)] + stop_hook_active: bool, +} + +impl CodexHookInput { + fn failure(&self, redactor: &Redactor) -> Option { + if self.tool_name.as_deref() != Some(Codex::SHELL) { + return None; + } + let command = self + .tool_input + .as_ref() + .and_then(|input| input.get("command")) + .and_then(Value::as_str) + .unwrap_or_default(); + let output = match self.tool_response.as_ref()? { + Value::String(text) => text.clone(), + Value::Object(fields) => ["output", "stdout", "stderr", "aggregated_output"] + .iter() + .filter_map(|key| fields.get(*key).and_then(Value::as_str)) + .collect::>() + .join("\n"), + _ => return None, + }; + Rollout::failed(command, &output, redactor).then_some(output) + } +} + +#[derive(Debug)] +pub(crate) struct Codex; + +impl Codex { + const HARNESS: &str = "codex"; + + const SHELL: &str = "Bash"; + + const TIMEOUT_SECONDS: u64 = 5; + + const SESSION_END_TIMEOUT_SECONDS: u64 = 3; + + // Trodden never writes Codex's trust hashes: that would skip the user's review. + pub(crate) const MANUAL_STEP: &str = "Start codex and choose \"Trust all and continue\" when it asks you to review the new hooks (or trust them in /hooks)."; + + const EVENTS: &[(&str, Option<&str>)] = &[ + ("SessionStart", None), + ("UserPromptSubmit", None), + ("PostToolUse", Some("^Bash$")), + ("Stop", None), + ("PreCompact", None), + ("SessionEnd", None), + ]; + + fn config() -> Result { + match env::var_os("CODEX_HOME").filter(|home| !home.is_empty()) { + Some(home) => Ok(PathBuf::from(home)), + None => Ok(env::home_dir() + .context("find the home directory")? + .join(".codex")), + } + } + + fn hooks(config: &Path) -> PathBuf { + config.join("hooks.json") + } + + // Codex runs hooks through `$SHELL -lc`, so the command stays a bare path and arguments + // that fish reads too. + fn connect_at(config: &Path, program: &Program) -> Result> { + let mut file = HookFile::load(Self::hooks(config))?; + file.remove(&["hooks"], Self::HARNESS); + let command = program.hook(Self::HARNESS); + for (event, matcher) in Self::EVENTS { + let timeout = if *event == "SessionEnd" { + Self::SESSION_END_TIMEOUT_SECONDS + } else { + Self::TIMEOUT_SECONDS + }; + let mut group = json!({"hooks": [{ + "type": "command", + "command": command, + "timeout": timeout, + }]}); + if let Some(matcher) = matcher { + group["matcher"] = json!(matcher); + } + file.add(&["hooks"], event, group)?; + } + Ok(file.change()?.into_iter().collect()) + } + + fn disconnect_at(config: &Path) -> Result> { + let mut file = HookFile::load(Self::hooks(config))?; + file.remove(&["hooks"], Self::HARNESS); + Ok(file.change()?.into_iter().collect()) + } + + fn connected_at(config: &Path) -> Result { + Ok(HookFile::load(Self::hooks(config))?.contains(&["hooks"], Self::HARNESS)) + } + + fn transcripts_in(sessions: &Path) -> Result> { + let mut directories = vec![sessions.to_path_buf()]; + if sessions.file_name().is_some_and(|name| name == "sessions") + && let Some(archived) = sessions + .parent() + .map(|codex| codex.join("archived_sessions")) + && archived.is_dir() + { + directories.push(archived); + } + fs::read_dir(sessions).with_context(|| format!("list {}", sessions.display()))?; + let mut transcripts = Vec::new(); + while let Some(directory) = directories.pop() { + let Ok(entries) = fs::read_dir(&directory) else { + continue; + }; + for path in entries.flatten().map(|entry| entry.path()) { + if path.is_dir() { + directories.push(path); + } else if Self::is_rollout(&path) && !Self::is_subagent(&path) { + transcripts.push(path); + } + } + } + transcripts.sort(); + Ok(transcripts) + } + + fn is_rollout(path: &Path) -> bool { + path.extension() + .is_some_and(|extension| extension == "jsonl") + && path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("rollout-")) + } + + fn is_subagent(path: &Path) -> bool { + let Ok(file) = fs::File::open(path) else { + return false; + }; + let mut first = String::new(); + BufReader::new(file).read_line(&mut first).is_ok() && Rollout::is_subagent(&first) + } +} + +impl Agent for Codex { + fn title(&self) -> &'static str { + "Codex" + } + + fn history(&self) -> Result> { + Ok(Some(Self::config()?.join("sessions"))) + } + + fn transcripts(&self, history: &Path) -> Result> { + Self::transcripts_in(history) + } + + fn parse(&self, text: &str, redactor: &Redactor) -> Result<(Trace, Option)> { + Ok(( + Rollout::parse(text, redactor)?, + Rollout::working_directory(text), + )) + } + + fn event(&self, payload: &str, redactor: &Redactor) -> Result> { + let input: CodexHookInput = + serde_json::from_str(payload).context("parse the hook payload")?; + let subagent = input.agent_id.is_some(); + let moment = match input.hook_event_name.as_str() { + "SessionStart" => Moment::SessionStart, + "UserPromptSubmit" => match &input.prompt { + Some(prompt) if !subagent => Moment::Prompt(prompt.clone()), + _ => return Ok(None), + }, + "PostToolUse" => match input.failure(redactor) { + Some(output) => Moment::CommandFailed(output), + None => return Ok(None), + }, + "Stop" => Moment::TurnEnd { + continued: input.stop_hook_active, + }, + "PreCompact" => Moment::Compacting, + "SessionEnd" => Moment::SessionEnd, + _ => return Ok(None), + }; + let Some(cwd) = input.cwd.filter(|cwd| cwd.is_absolute()) else { + return Ok(None); + }; + Ok(Some(HookEvent { + name: input.hook_event_name, + session: input.session_id, + cwd, + transcript: input + .transcript_path + .filter(|path| !path.as_os_str().is_empty()), + moment, + observed: Vec::new(), + })) + } + + // Codex drops a reply with unknown fields or another event's `hookEventName`. + fn render(&self, event: &HookEvent, reply: Reply<'_>) -> Option { + match (&event.moment, reply) { + (Moment::Prompt(_), Reply::Recall(envelope)) => Some(ClaudeHookInput::recall_context( + "UserPromptSubmit", + envelope, + )), + (Moment::CommandFailed(_), Reply::Recall(envelope)) => { + Some(ClaudeHookInput::recall_context("PostToolUse", envelope)) + } + (Moment::TurnEnd { .. }, Reply::Remind(reason)) => Some(ClaudeHookInput::block(reason)), + _ => None, + } + } + + fn detected(&self) -> bool { + Self::config().is_ok_and(|config| config.is_dir()) + } + + fn notice(&self) -> Option<&'static str> { + Some(Self::MANUAL_STEP) + } + + fn connect(&self, program: &Program) -> Result> { + Self::connect_at(&Self::config()?, program) + } + + fn disconnect(&self) -> Result> { + Self::disconnect_at(&Self::config()?) + } + + fn connected(&self) -> Result { + Self::connected_at(&Self::config()?) + } +} + +#[cfg(test)] +mod tests { + use std::process; + + use super::*; + + #[derive(Debug)] + struct Payload; + + impl Payload { + fn event(extra: Value) -> Option { + let mut payload = json!({ + "session_id": "019f0a10-5c2e-7d41-9b8a-3e6f1c2d4a5b", + "turn_id": "019f0a13-0000-7000-8000-000000000003", + "transcript_path": "/home/dev/.codex/sessions/2026/06/07/rollout-2026-06-07T17-48-19-019f0a10-5c2e-7d41-9b8a-3e6f1c2d4a5b.jsonl", + "cwd": "/home/dev/shop", + "model": "gpt-5.5", + "permission_mode": "default", + }); + payload + .as_object_mut() + .expect("object") + .extend(extra.as_object().expect("object").clone()); + Codex + .event(&payload.to_string(), &Redactor::with_home("/home/dev")) + .expect("payload parses") + } + + fn moment(extra: Value) -> Option { + Self::event(extra).map(|event| event.moment) + } + + fn rendered(moment: Moment, reply: Reply<'_>) -> Option { + let event = HookEvent { + name: String::new(), + session: "s".to_owned(), + cwd: PathBuf::from("/home/dev/shop"), + transcript: None, + moment, + observed: Vec::new(), + }; + Codex.render(&event, reply) + } + } + + #[derive(Debug)] + struct Scratch { + dir: PathBuf, + } + + impl Scratch { + fn new(name: &str) -> Self { + let dir = env::temp_dir().join(format!("trodden-codex-{name}-{}", process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("scratch dir is writable"); + Self { dir } + } + + fn apply(changes: Vec) { + for change in changes { + change.apply().expect("change applies"); + } + } + + fn hooks(&self) -> Value { + serde_json::from_str( + &fs::read_to_string(Codex::hooks(&self.dir)).expect("hooks are readable"), + ) + .expect("hooks are JSON") + } + + fn write(&self, relative: &str, text: &str) -> PathBuf { + let path = self.dir.join(relative); + fs::create_dir_all(path.parent().expect("has a parent")).expect("dir is writable"); + fs::write(&path, text).expect("file is writable"); + path + } + } + + impl Drop for Scratch { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.dir); + } + } + + #[test] + fn hook_events_map_to_moments() { + assert_eq!( + Payload::moment(json!({"hook_event_name": "UserPromptSubmit", "prompt": "Fix paging"})), + Some(Moment::Prompt("Fix paging".to_owned())) + ); + assert_eq!( + Payload::moment(json!({"hook_event_name": "SessionStart", "source": "startup"})), + Some(Moment::SessionStart) + ); + assert_eq!( + Payload::moment( + json!({"hook_event_name": "Stop", "stop_hook_active": true, "last_assistant_message": null}) + ), + Some(Moment::TurnEnd { continued: true }) + ); + assert_eq!( + Payload::moment(json!({"hook_event_name": "PreCompact", "trigger": "auto"})), + Some(Moment::Compacting) + ); + assert_eq!( + Payload::moment(json!({"hook_event_name": "SessionEnd", "reason": "other"})), + Some(Moment::SessionEnd) + ); + assert_eq!( + Payload::moment( + json!({"hook_event_name": "SubagentStop", "agent_id": "a", "agent_type": "worker"}) + ), + None + ); + let event = Payload::event(json!({"hook_event_name": "Stop"})).expect("event"); + assert_eq!(event.session, "019f0a10-5c2e-7d41-9b8a-3e6f1c2d4a5b"); + assert!(event.transcript.is_some_and(|path| { + path.ends_with("rollout-2026-06-07T17-48-19-019f0a10-5c2e-7d41-9b8a-3e6f1c2d4a5b.jsonl") + })); + } + + #[test] + fn subagent_prompts_and_odd_payloads_are_ignored() { + assert_eq!( + Payload::moment( + json!({"hook_event_name": "UserPromptSubmit", "prompt": "Review the diff", "agent_id": "a1", "agent_type": "reviewer"}) + ), + None + ); + assert_eq!( + Payload::moment(json!({"hook_event_name": "Stop", "cwd": "shop"})), + None + ); + let event = Payload::event(json!({"hook_event_name": "Stop", "transcript_path": null})) + .expect("event"); + assert_eq!(event.transcript, None); + assert!(Codex.event("not json", &Redactor::new()).is_err()); + } + + #[test] + fn failed_shell_commands_are_read_from_their_output() { + let failing = " Compiling shop v0.1.0\nerror[E0425]: cannot find value `total` in this scope\n --> src/lib.rs:3:5\n"; + + assert_eq!( + Payload::moment( + json!({"hook_event_name": "PostToolUse", "tool_name": "Bash", + "tool_input": {"command": "cargo test"}, "tool_response": failing, "tool_use_id": "call_1"}) + ), + Some(Moment::CommandFailed(failing.to_owned())) + ); + assert_eq!( + Payload::moment( + json!({"hook_event_name": "PostToolUse", "tool_name": "Bash", + "tool_input": {"command": "cargo test"}, "tool_response": "test result: ok. 3 passed; 0 failed\n"}) + ), + None + ); + assert_eq!( + Payload::moment( + json!({"hook_event_name": "PostToolUse", "tool_name": "Bash", + "tool_input": {"command": "cat src/lib.rs"}, "tool_response": failing}) + ), + None + ); + assert_eq!( + Payload::moment( + json!({"hook_event_name": "PostToolUse", "tool_name": "apply_patch", + "tool_input": {"command": "*** Begin Patch"}, "tool_response": failing}) + ), + None + ); + } + + #[test] + fn replies_take_the_shape_codex_accepts() { + let prompt = Payload::rendered(Moment::Prompt("p".to_owned()), Reply::Recall("")) + .expect("prompt recall renders"); + assert_eq!( + serde_json::from_str::(&prompt).expect("JSON"), + json!({"hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": ""}}) + ); + let failure = + Payload::rendered(Moment::CommandFailed("e".to_owned()), Reply::Recall("")) + .expect("failure recall renders"); + assert_eq!( + serde_json::from_str::(&failure).expect("JSON"), + json!({"hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": ""}}) + ); + assert_eq!( + Payload::rendered( + Moment::TurnEnd { continued: false }, + Reply::Remind("run it") + ), + Some(r#"{"decision":"block","reason":"run it"}"#.to_owned()) + ); + assert_eq!( + Payload::rendered(Moment::Compacting, Reply::Recall("")), + None + ); + } + + #[test] + fn connect_keeps_the_users_hooks_and_round_trips() { + let scratch = Scratch::new("connect"); + scratch.write( + "hooks.json", + r#"{"hooks":{"SessionStart":[{"hooks":[{"command":"bash '/home/dev/.codex/notify.sh' session","timeout":10,"type":"command"}]}]}}"#, + ); + let program = Program::at("/home/dev/.cargo/bin/trodden"); + + assert!(!Codex::connected_at(&scratch.dir).expect("hooks load")); + Scratch::apply(Codex::connect_at(&scratch.dir, &program).expect("connect")); + let hooks = scratch.hooks(); + + assert!(Codex::connected_at(&scratch.dir).expect("hooks load")); + assert_eq!( + hooks["hooks"]["SessionStart"][0]["hooks"][0]["command"], + "bash '/home/dev/.codex/notify.sh' session" + ); + assert_eq!( + hooks["hooks"]["SessionStart"][1]["hooks"][0]["command"], + "/home/dev/.cargo/bin/trodden hook codex" + ); + assert_eq!( + hooks["hooks"]["PostToolUse"], + json!([{"matcher": "^Bash$", "hooks": [{"type": "command", "command": "/home/dev/.cargo/bin/trodden hook codex", "timeout": 5}]}]) + ); + assert_eq!(hooks["hooks"]["SessionEnd"][0]["hooks"][0]["timeout"], 3); + let events: Vec<&String> = hooks["hooks"].as_object().expect("events").keys().collect(); + assert_eq!( + events, + [ + "SessionStart", + "UserPromptSubmit", + "PostToolUse", + "Stop", + "PreCompact", + "SessionEnd" + ] + ); + + assert!( + Codex::connect_at(&scratch.dir, &program) + .expect("connect again") + .is_empty(), + "connecting twice changes nothing" + ); + + Scratch::apply(Codex::disconnect_at(&scratch.dir).expect("disconnect")); + assert!(!Codex::connected_at(&scratch.dir).expect("hooks load")); + assert_eq!( + scratch.hooks(), + json!({"hooks":{"SessionStart":[{"hooks":[{"command":"bash '/home/dev/.codex/notify.sh' session","timeout":10,"type":"command"}]}]}}) + ); + assert!(!Codex::MANUAL_STEP.is_empty()); + } + + #[test] + fn backfill_finds_rollouts_but_not_subagents() { + let scratch = Scratch::new("backfill"); + let meta = |source: Value| { + format!( + "{}\n", + json!({"timestamp": "2026-06-07T12:21:17.584Z", "type": "session_meta", + "payload": {"id": "019f0a10", "cwd": "/home/dev/shop", "source": source}}) + ) + }; + let main = scratch.write( + "sessions/2026/06/07/rollout-2026-06-07T17-48-19-019f0a10.jsonl", + &meta(json!("cli")), + ); + scratch.write( + "sessions/2026/06/07/rollout-2026-06-07T17-50-00-019f0c00.jsonl", + &meta( + json!({"subagent": {"thread_spawn": {"parent_thread_id": "019f0a10", "depth": 1}}}), + ), + ); + scratch.write("sessions/2026/06/07/notes.txt", "not a rollout"); + let archived = scratch.write( + "archived_sessions/rollout-2026-05-01T09-00-00-019e0000.jsonl", + &meta(json!("exec")), + ); + + let transcripts = + Codex::transcripts_in(&scratch.dir.join("sessions")).expect("sessions list"); + + assert_eq!(transcripts, [archived, main]); + } +} diff --git a/crates/trodden/src/harness/copilot.rs b/crates/trodden/src/harness/copilot.rs new file mode 100644 index 0000000..7c6360f --- /dev/null +++ b/crates/trodden/src/harness/copilot.rs @@ -0,0 +1,626 @@ +use std::{ + env, fs, + path::{Path, PathBuf}, +}; + +use anyhow::{Context, Result}; +use serde::Deserialize; +use serde_json::{Value, json}; +use trodden_capture::copilot::Events; +use trodden_core::Trace; +use trodden_redact::Redactor; + +use super::{Agent, Change, HookEvent, Moment, Reply, claude_code::ClaudeHookInput}; +use crate::connect::{HookFile, Program}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CopilotHookInput { + #[serde(alias = "session_id")] + session_id: String, + #[serde(default)] + cwd: Option, + #[serde(default, alias = "hook_event_name")] + hook_event_name: Option, + #[serde(default, alias = "transcript_path")] + transcript_path: Option, + #[serde(default)] + prompt: Option, + #[serde(default, alias = "transformed_prompt")] + transformed_prompt: Option, + #[serde(default, alias = "tool_name")] + tool_name: Option, + #[serde(default, alias = "tool_input")] + tool_args: Option, + #[serde(default, alias = "tool_result")] + tool_result: Option, + #[serde(default)] + error: Option, + #[serde(default, alias = "stop_reason")] + stop_reason: Option, + #[serde(default, rename = "stop_hook_active", alias = "stopHookActive")] + stop_hook_active: bool, + #[serde(default)] + trigger: Option, + #[serde(default)] + reason: Option, + #[serde(default)] + source: Option, + #[serde(default, alias = "agent_name")] + agent_name: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CopilotEvent { + SessionStart, + Prompt, + ToolDone, + ToolFailed, + Stop, + PreCompact, + SessionEnd, +} + +impl CopilotHookInput { + fn kind(&self) -> Option { + if let Some(name) = &self.hook_event_name { + return match name.as_str() { + "sessionStart" | "SessionStart" => Some(CopilotEvent::SessionStart), + "userPromptSubmitted" | "UserPromptSubmit" => Some(CopilotEvent::Prompt), + "postToolUse" | "PostToolUse" => Some(CopilotEvent::ToolDone), + "postToolUseFailure" | "PostToolUseFailure" => Some(CopilotEvent::ToolFailed), + "agentStop" | "Stop" => Some(CopilotEvent::Stop), + "preCompact" | "PreCompact" => Some(CopilotEvent::PreCompact), + "sessionEnd" | "SessionEnd" => Some(CopilotEvent::SessionEnd), + _ => None, + }; + } + if self.agent_name.is_some() || self.transformed_prompt.is_some() { + return None; + } + if self.tool_name.is_some() { + return if self.tool_result.is_some() { + Some(CopilotEvent::ToolDone) + } else if self.error.is_some() { + Some(CopilotEvent::ToolFailed) + } else { + None + }; + } + if self.prompt.is_some() { + Some(CopilotEvent::Prompt) + } else if self.stop_reason.is_some() { + Some(CopilotEvent::Stop) + } else if self.trigger.is_some() { + Some(CopilotEvent::PreCompact) + } else if self.reason.is_some() { + Some(CopilotEvent::SessionEnd) + } else if self.source.is_some() { + Some(CopilotEvent::SessionStart) + } else { + None + } + } + + fn is_shell(&self) -> bool { + self.tool_name + .as_deref() + .is_some_and(|tool| Copilot::SHELLS.contains(&tool)) + } + + fn command(&self) -> String { + let args = match &self.tool_args { + Some(Value::String(text)) => serde_json::from_str(text).unwrap_or_default(), + Some(value) => value.clone(), + None => Value::Null, + }; + args.get("command") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned() + } + + fn failure(&self, kind: CopilotEvent, redactor: &Redactor) -> Option { + if !self.is_shell() { + return None; + } + match kind { + CopilotEvent::ToolDone => { + let result = self.tool_result.as_ref()?; + let output = ["textResultForLlm", "text_result_for_llm"] + .iter() + .find_map(|key| result.get(*key).and_then(Value::as_str))?; + let kind = ["resultType", "result_type"] + .iter() + .find_map(|key| result.get(*key).and_then(Value::as_str)); + (kind == Some("failure") || Events::failed(&self.command(), output, redactor)) + .then(|| output.to_owned()) + } + CopilotEvent::ToolFailed => { + let error = match self.error.as_ref()? { + Value::String(text) => text.clone(), + other => other.get("message")?.as_str()?.to_owned(), + }; + let lowered = error.to_lowercase(); + let declined = [ + "rejected", + "denied by the user", + "user denied", + "cancelled", + "canceled", + "aborted", + ] + .iter() + .any(|phrase| lowered.contains(phrase)); + (!declined && !error.trim().is_empty()).then_some(error) + } + _ => None, + } + } +} + +#[derive(Debug)] +pub(crate) struct Copilot; + +impl Copilot { + const HARNESS: &str = "copilot"; + + const SHELLS: &[&str] = &["bash", "powershell", "Bash", "PowerShell"]; + + const TIMEOUT_SECONDS: u64 = 5; + + const EVENTS: &[(&str, Option<&str>)] = &[ + ("sessionStart", None), + ("userPromptSubmitted", None), + ("postToolUse", Some("bash|powershell")), + ("postToolUseFailure", None), + ("agentStop", None), + ("preCompact", None), + ("sessionEnd", None), + ]; + + fn config() -> Result { + match env::var_os("COPILOT_HOME").filter(|home| !home.is_empty()) { + Some(home) => Ok(PathBuf::from(home)), + None => Ok(env::home_dir() + .context("find the home directory")? + .join(".copilot")), + } + } + + fn hooks(config: &Path) -> PathBuf { + config.join("hooks").join("trodden.json") + } + + fn connect_at(config: &Path, program: &Program) -> Result> { + let command = program.hook(Self::HARNESS); + let mut hooks = serde_json::Map::new(); + for (event, matcher) in Self::EVENTS { + let mut handler = json!({ + "type": "command", + "bash": command, + "powershell": format!("& {command}"), + "timeoutSec": Self::TIMEOUT_SECONDS, + }); + if let Some(matcher) = matcher { + handler["matcher"] = json!(matcher); + } + hooks.insert((*event).to_owned(), json!([handler])); + } + let mut text = serde_json::to_string_pretty(&json!({"version": 1, "hooks": hooks})) + .context("encode the Copilot hooks")?; + text.push('\n'); + let change = Change::write(Self::hooks(config), text); + Ok(if change.is_needed() { + vec![change] + } else { + Vec::new() + }) + } + + fn disconnect_at(config: &Path) -> Result> { + Ok(if Self::connected_at(config)? { + vec![Change::remove(Self::hooks(config))] + } else { + Vec::new() + }) + } + + fn connected_at(config: &Path) -> Result { + let path = Self::hooks(config); + Ok(path.is_file() && HookFile::load(path)?.contains(&["hooks"], Self::HARNESS)) + } + + fn transcript_of(config: &Path, session: &str) -> Option { + let plain = !session.is_empty() + && session + .chars() + .all(|c| c.is_ascii_alphanumeric() || "-_".contains(c)); + let path = config + .join("session-state") + .join(session) + .join("events.jsonl"); + (plain && path.is_file()).then_some(path) + } + + fn transcripts_in(sessions: &Path) -> Result> { + let mut transcripts = Vec::new(); + for entry in + fs::read_dir(sessions).with_context(|| format!("list {}", sessions.display()))? + { + let path = entry + .context("read a session directory entry")? + .path() + .join("events.jsonl"); + if path.is_file() { + transcripts.push(path); + } + } + transcripts.sort(); + Ok(transcripts) + } + + fn event_at( + config: Option<&Path>, + payload: &str, + redactor: &Redactor, + ) -> Result> { + let input: CopilotHookInput = + serde_json::from_str(payload).context("parse the hook payload")?; + let Some(kind) = input.kind() else { + return Ok(None); + }; + let moment = match kind { + CopilotEvent::SessionStart => Moment::SessionStart, + CopilotEvent::Prompt => match &input.prompt { + Some(prompt) => Moment::Prompt(prompt.clone()), + None => return Ok(None), + }, + CopilotEvent::ToolDone | CopilotEvent::ToolFailed => { + match input.failure(kind, redactor) { + Some(error) => Moment::CommandFailed(error), + None => return Ok(None), + } + } + CopilotEvent::Stop => Moment::TurnEnd { + continued: input.stop_hook_active, + }, + CopilotEvent::PreCompact => Moment::Compacting, + CopilotEvent::SessionEnd => Moment::SessionEnd, + }; + let Some(cwd) = input.cwd.filter(|cwd| cwd.is_absolute()) else { + return Ok(None); + }; + let transcript = input + .transcript_path + .filter(|path| !path.as_os_str().is_empty()) + .or_else(|| config.and_then(|config| Self::transcript_of(config, &input.session_id))); + Ok(Some(HookEvent { + name: input.hook_event_name.unwrap_or_else(|| format!("{kind:?}")), + session: input.session_id, + cwd, + transcript, + moment, + observed: Vec::new(), + })) + } +} + +impl Agent for Copilot { + fn title(&self) -> &'static str { + "GitHub Copilot CLI" + } + + fn history(&self) -> Result> { + Ok(Some(Self::config()?.join("session-state"))) + } + + fn transcripts(&self, history: &Path) -> Result> { + Self::transcripts_in(history) + } + + fn parse(&self, text: &str, redactor: &Redactor) -> Result<(Trace, Option)> { + Ok(( + Events::parse(text, redactor)?, + Events::working_directory(text), + )) + } + + fn event(&self, payload: &str, redactor: &Redactor) -> Result> { + Self::event_at(Self::config().ok().as_deref(), payload, redactor) + } + + // UNVERIFIED: the docs say `userPromptSubmitted` output is dropped, the changelog says + // `additionalContext` is honored. + fn render(&self, event: &HookEvent, reply: Reply<'_>) -> Option { + match (&event.moment, reply) { + (Moment::Prompt(_) | Moment::CommandFailed(_), Reply::Recall(envelope)) => { + Some(json!({ "additionalContext": envelope }).to_string()) + } + (Moment::TurnEnd { .. }, Reply::Remind(reason)) => Some(ClaudeHookInput::block(reason)), + _ => None, + } + } + + fn detected(&self) -> bool { + Self::config().is_ok_and(|config| config.is_dir()) + } + + fn connect(&self, program: &Program) -> Result> { + Self::connect_at(&Self::config()?, program) + } + + fn disconnect(&self) -> Result> { + Self::disconnect_at(&Self::config()?) + } + + fn connected(&self) -> Result { + Self::connected_at(&Self::config()?) + } +} + +#[cfg(test)] +mod tests { + use std::process; + + use super::*; + + const SESSION: &str = "3f2c9a4e-8b1d-4c6e-9a7f-1d2e3f4a5b6c"; + + #[derive(Debug)] + struct Scratch { + dir: PathBuf, + } + + impl Scratch { + fn new(name: &str) -> Self { + let dir = env::temp_dir().join(format!("trodden-copilot-{name}-{}", process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("scratch dir is writable"); + Self { dir } + } + + fn apply(changes: Vec) { + for change in changes { + change.apply().expect("change applies"); + } + } + + fn event(&self, payload: Value) -> Option { + Copilot::event_at( + Some(&self.dir), + &payload.to_string(), + &Redactor::with_home("/home/dev"), + ) + .expect("payload parses") + } + + fn moment(&self, extra: Value) -> Option { + let mut payload = json!({"sessionId": SESSION, "timestamp": 1_791_540_000_000_u64, "cwd": "/home/dev/shop"}); + payload + .as_object_mut() + .expect("object") + .extend(extra.as_object().expect("object").clone()); + self.event(payload).map(|event| event.moment) + } + } + + impl Drop for Scratch { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.dir); + } + } + + #[test] + fn camel_case_events_are_told_apart_by_their_fields() { + let scratch = Scratch::new("events"); + + assert_eq!( + scratch.moment(json!({"source": "startup", "initialPrompt": "hi"})), + Some(Moment::SessionStart) + ); + assert_eq!( + scratch.moment(json!({"prompt": "Fix paging"})), + Some(Moment::Prompt("Fix paging".to_owned())) + ); + assert_eq!( + scratch.moment(json!({"transcriptPath": "/t/events.jsonl", "stopReason": "end_turn", "stop_hook_active": true})), + Some(Moment::TurnEnd { continued: true }) + ); + assert_eq!( + scratch.moment(json!({"transcriptPath": "/t/events.jsonl", "trigger": "auto", "customInstructions": ""})), + Some(Moment::Compacting) + ); + assert_eq!( + scratch.moment(json!({"reason": "user_exit"})), + Some(Moment::SessionEnd) + ); + assert_eq!( + scratch.moment(json!({"prompt": "p", "transformedPrompt": "p"})), + None + ); + assert_eq!( + scratch.moment( + json!({"transcriptPath": "/t", "agentName": "explore", "stopReason": "end_turn"}) + ), + None + ); + assert_eq!( + scratch.moment(json!({"toolName": "bash", "toolArgs": {"command": "npm test"}})), + None + ); + assert_eq!(scratch.moment(json!({})), None); + } + + #[test] + fn pascal_case_events_carry_their_name() { + let scratch = Scratch::new("pascal"); + let event = scratch + .event(json!({"hook_event_name": "Stop", "session_id": SESSION, "timestamp": "2026-10-09T10:00:00Z", + "cwd": "/home/dev/shop", "transcript_path": "/home/dev/.copilot/session-state/s/events.jsonl", + "stop_reason": "end_turn", "stop_hook_active": false})) + .expect("event"); + + assert_eq!(event.moment, Moment::TurnEnd { continued: false }); + assert_eq!(event.name, "Stop"); + assert_eq!( + event.transcript, + Some(PathBuf::from( + "/home/dev/.copilot/session-state/s/events.jsonl" + )) + ); + assert_eq!( + scratch.moment(json!({"hook_event_name": "UserPromptSubmit", "prompt": "Fix paging"})), + Some(Moment::Prompt("Fix paging".to_owned())) + ); + assert_eq!( + scratch.moment(json!({"hook_event_name": "Notification", "message": "x"})), + None + ); + } + + #[test] + fn shell_failures_come_from_either_tool_event() { + let scratch = Scratch::new("failures"); + let failing = "Error: Cannot find module 'left-pad'\n"; + + assert_eq!( + scratch.moment( + json!({"toolName": "bash", "toolArgs": "{\"command\":\"npm test\"}", + "toolResult": {"resultType": "success", "textResultForLlm": failing}}) + ), + Some(Moment::CommandFailed(failing.to_owned())) + ); + assert_eq!( + scratch.moment(json!({"toolName": "bash", "toolArgs": {"command": "npm test"}, + "toolResult": {"resultType": "success", "textResultForLlm": "1 passing\n"}})), + None + ); + assert_eq!( + scratch.moment(json!({"toolName": "view", "toolArgs": {"path": "a"}, + "toolResult": {"resultType": "success", "textResultForLlm": failing}})), + None + ); + assert_eq!( + scratch.moment(json!({"toolName": "bash", "toolArgs": {"command": "npm test"}, "error": "Command timed out: npm test"})), + Some(Moment::CommandFailed("Command timed out: npm test".to_owned())) + ); + assert_eq!( + scratch.moment(json!({"toolName": "bash", "toolArgs": {"command": "rm -rf /"}, "error": "The user rejected this tool call"})), + None + ); + assert_eq!( + scratch.moment(json!({"hook_event_name": "PostToolUse", "tool_name": "bash", "tool_input": {"command": "npm test"}, + "tool_result": {"result_type": "success", "text_result_for_llm": failing}})), + Some(Moment::CommandFailed(failing.to_owned())) + ); + } + + #[test] + fn the_transcript_is_found_by_session_id() { + let scratch = Scratch::new("transcript"); + let events = scratch + .dir + .join("session-state") + .join(SESSION) + .join("events.jsonl"); + fs::create_dir_all(events.parent().expect("has a parent")).expect("dir is writable"); + fs::write(&events, "{}\n").expect("written"); + + let event = scratch + .event(json!({"sessionId": SESSION, "cwd": "/home/dev/shop", "reason": "complete"})) + .expect("event"); + let traversal = scratch + .event(json!({"sessionId": "../../etc", "cwd": "/home/dev/shop", "reason": "complete"})) + .expect("event"); + + assert_eq!(event.transcript, Some(events)); + assert_eq!(traversal.transcript, None); + assert_eq!( + Copilot::transcripts_in(&scratch.dir.join("session-state")).expect("sessions list"), + [event.transcript.expect("found")] + ); + } + + #[test] + fn replies_are_json_objects() { + let event = |moment| HookEvent { + name: String::new(), + session: "s".to_owned(), + cwd: PathBuf::from("/w"), + transcript: None, + moment, + observed: Vec::new(), + }; + + assert_eq!( + Copilot.render( + &event(Moment::Prompt("p".to_owned())), + Reply::Recall("") + ), + Some(r#"{"additionalContext":""}"#.to_owned()) + ); + assert_eq!( + Copilot.render( + &event(Moment::CommandFailed("e".to_owned())), + Reply::Recall("") + ), + Some(r#"{"additionalContext":""}"#.to_owned()) + ); + assert_eq!( + Copilot.render( + &event(Moment::TurnEnd { continued: false }), + Reply::Remind("run it") + ), + Some(r#"{"decision":"block","reason":"run it"}"#.to_owned()) + ); + assert_eq!( + Copilot.render(&event(Moment::SessionEnd), Reply::Remind("x")), + None + ); + } + + #[test] + fn connect_owns_one_hooks_file() { + let scratch = Scratch::new("connect"); + let mine = scratch.dir.join("hooks").join("mine.json"); + fs::create_dir_all(mine.parent().expect("has a parent")).expect("dir is writable"); + fs::write( + &mine, + r#"{"version":1,"hooks":{"sessionEnd":[{"bash":"afplay done.aiff"}]}}"#, + ) + .expect("written"); + let program = Program::at("/home/dev/.cargo/bin/trodden"); + + assert!(!Copilot::connected_at(&scratch.dir).expect("hooks load")); + Scratch::apply(Copilot::connect_at(&scratch.dir, &program).expect("connect")); + let hooks: Value = serde_json::from_str( + &fs::read_to_string(Copilot::hooks(&scratch.dir)).expect("hooks are readable"), + ) + .expect("hooks are JSON"); + + assert!(Copilot::connected_at(&scratch.dir).expect("hooks load")); + assert_eq!(hooks["version"], 1); + assert_eq!( + hooks["hooks"]["postToolUse"], + json!([{"type": "command", "bash": "/home/dev/.cargo/bin/trodden hook copilot", + "powershell": "& /home/dev/.cargo/bin/trodden hook copilot", "timeoutSec": 5, "matcher": "bash|powershell"}]) + ); + assert_eq!(hooks["hooks"].as_object().expect("events").len(), 7); + assert!( + Copilot::connect_at(&scratch.dir, &program) + .expect("connect") + .is_empty() + ); + + Scratch::apply(Copilot::disconnect_at(&scratch.dir).expect("disconnect")); + assert!(!Copilot::connected_at(&scratch.dir).expect("hooks load")); + assert!(!Copilot::hooks(&scratch.dir).exists()); + assert!(mine.exists(), "the user's own hooks stay"); + assert!( + Copilot::disconnect_at(&scratch.dir) + .expect("disconnect") + .is_empty() + ); + } +} diff --git a/crates/trodden/src/harness/cursor.rs b/crates/trodden/src/harness/cursor.rs new file mode 100644 index 0000000..b003c80 --- /dev/null +++ b/crates/trodden/src/harness/cursor.rs @@ -0,0 +1,816 @@ +use std::{ + env, + path::{Path, PathBuf}, +}; + +use anyhow::{Context, Result}; +use jiff::Timestamp; +use serde::Deserialize; +use serde_json::{Value, json}; +use trodden_capture::{ + Reminder, + journal::{Edit, Journal, Observation, Observer, Ran}, +}; +use trodden_core::{Trace, trace::ToolAction}; +use trodden_redact::Redactor; + +use super::{Agent, Change, HookEvent, Moment, Reply}; +use crate::connect::{HookFile, Program}; + +#[derive(Debug, Deserialize)] +struct CursorHookInput { + hook_event_name: String, + #[serde(default)] + conversation_id: Option, + #[serde(default)] + session_id: Option, + #[serde(default)] + cwd: Option, + #[serde(default)] + workspace_roots: Vec, + #[serde(default)] + model: Option, + #[serde(default)] + model_id: Option, + #[serde(default)] + prompt: Option, + #[serde(default)] + tool_name: Option, + #[serde(default)] + tool_input: Option, + #[serde(default)] + tool_output: Option, + #[serde(default)] + duration: Option, + #[serde(default)] + error_message: Option, + #[serde(default)] + failure_type: Option, + #[serde(default)] + is_interrupt: bool, + #[serde(default)] + parent_tool_call_id: Option, + #[serde(default)] + file_path: Option, + #[serde(default)] + edits: Vec, + #[serde(default)] + loop_count: Option, + #[serde(default)] + trigger: Option, +} + +#[derive(Debug, Deserialize)] +struct CursorEdit { + #[serde(default)] + old_string: String, + #[serde(default)] + new_string: String, +} + +#[derive(Debug, Default, PartialEq, Eq)] +struct ShellResult { + exit_code: Option, + output: String, +} + +#[derive(Debug)] +pub(crate) struct Cursor; + +impl Cursor { + const HARNESS: &str = "cursor"; + + const SHELL: &str = "Shell"; + + const TIMEOUT_SECONDS: u64 = 5; + + const EDIT_TOOLS: &[&str] = &[ + "Write", + "StrReplace", + "Edit", + "MultiEdit", + "edit_file", + "search_replace", + "ApplyPatch", + ]; + + const BOOKKEEPING_TOOLS: &[&str] = &["TodoWrite", "todo_write", "AskQuestion", "SwitchMode"]; + + const POST_TOOL_MATCHER: &str = "Shell|Read|Grep|Glob|Delete|Task|WebFetch|WebSearch"; + + const EVENTS: &[(&str, Option<&str>)] = &[ + ("sessionStart", None), + ("beforeSubmitPrompt", None), + ("postToolUse", Some(Self::POST_TOOL_MATCHER)), + ("postToolUseFailure", Some(Self::SHELL)), + ("afterFileEdit", None), + ("stop", None), + ("preCompact", None), + ("sessionEnd", None), + ]; + + fn config() -> Result { + Ok(env::home_dir() + .context("find the home directory")? + .join(".cursor")) + } + + fn hooks(config: &Path) -> PathBuf { + config.join("hooks.json") + } + + fn connect_at(config: &Path, program: &Program) -> Result> { + let mut file = HookFile::load(Self::hooks(config))?; + file.remove(&["hooks"], Self::HARNESS); + if file.get("version").is_none() { + file.set("version", json!(1))?; + } + let command = program.hook(Self::HARNESS); + for (event, matcher) in Self::EVENTS { + let mut handler = json!({ + "type": "command", + "command": command, + "timeout": Self::TIMEOUT_SECONDS, + }); + if let Some(matcher) = matcher { + handler["matcher"] = json!(matcher); + } + if *event == "stop" { + handler["loop_limit"] = json!(1); + } + file.add(&["hooks"], event, handler)?; + } + Ok(file.change()?.into_iter().collect()) + } + + fn disconnect_at(config: &Path) -> Result> { + let mut file = HookFile::load(Self::hooks(config))?; + file.remove(&["hooks"], Self::HARNESS); + Ok(file.change()?.into_iter().collect()) + } + + fn connected_at(config: &Path) -> Result { + Ok(HookFile::load(Self::hooks(config))?.contains(&["hooks"], Self::HARNESS)) + } + + fn string<'v>(value: Option<&'v Value>, keys: &[&str]) -> Option<&'v str> { + let value = value?; + keys.iter() + .find_map(|key| value.get(key).and_then(Value::as_str)) + .filter(|text| !text.is_empty()) + } + + fn shell_result(output: Option<&Value>) -> ShellResult { + let decoded = match output { + Some(Value::String(text)) => match serde_json::from_str::(text) { + Ok(value @ Value::Object(_)) => value, + _ => { + return ShellResult { + exit_code: None, + output: text.clone(), + }; + } + }, + Some(value @ Value::Object(_)) => value.clone(), + _ => return ShellResult::default(), + }; + let exit_code = ["exitCode", "exit_code", "code"] + .iter() + .find_map(|key| decoded.get(key).and_then(Value::as_i64)) + .and_then(|code| i32::try_from(code).ok()); + let output: Vec<&str> = ["stdout", "stderr", "output", "error"] + .iter() + .filter_map(|key| decoded.get(key).and_then(Value::as_str)) + .filter(|text| !text.trim().is_empty()) + .collect(); + ShellResult { + exit_code, + output: output.join("\n"), + } + } + + fn patch(edits: &[CursorEdit]) -> String { + let mut patch = String::new(); + for edit in edits { + let old: Vec<&str> = edit.old_string.lines().collect(); + let new: Vec<&str> = edit.new_string.lines().collect(); + let prefix = old.iter().zip(&new).take_while(|(a, b)| a == b).count(); + let suffix = old[prefix..] + .iter() + .rev() + .zip(new[prefix..].iter().rev()) + .take_while(|(a, b)| a == b) + .count(); + patch.push_str("@@\n"); + for line in &old[prefix..old.len() - suffix] { + patch.push_str(&format!("-{line}\n")); + } + for line in &new[prefix..new.len() - suffix] { + patch.push_str(&format!("+{line}\n")); + } + } + patch + } + + fn observe_tool( + input: &CursorHookInput, + observer: &Observer<'_>, + failed: bool, + ) -> Option { + let tool = input.tool_name.as_deref()?; + if Self::EDIT_TOOLS.contains(&tool) || Self::BOOKKEEPING_TOOLS.contains(&tool) { + return None; + } + let args = input.tool_input.as_ref(); + let path = Self::string(args, &["path", "file_path", "target_file", "filePath"]); + Some(match tool { + Self::SHELL => return None, + "Read" | "read_file" => observer.read(tool, path?), + "Grep" | "Glob" | "grep_search" | "file_search" | "codebase_search" | "list_dir" => { + observer.search( + tool, + Self::string(args, &["pattern", "query", "glob_pattern", "glob"]), + Self::string(args, &["path", "target_directory", "directory"]), + ) + } + "Delete" | "delete_file" => observer.edit(tool, path?, Edit::Unknown, !failed), + "WebFetch" | "WebSearch" | "web_search" => observer.fetch( + tool, + Self::string(args, &["url"]), + Self::string(args, &["query", "search_term"]), + ), + "Task" => observer.other(tool, ToolAction::Delegate), + _ => observer.other(tool, ToolAction::Other), + }) + } +} + +impl Agent for Cursor { + fn title(&self) -> &'static str { + "Cursor" + } + + fn journaled(&self) -> bool { + true + } + + fn history(&self) -> Result> { + Ok(None) + } + + fn transcripts(&self, _history: &Path) -> Result> { + Ok(Vec::new()) + } + + fn parse(&self, text: &str, redactor: &Redactor) -> Result<(Trace, Option)> { + Ok(( + Journal::parse(text, Self::HARNESS, redactor)?, + Journal::working_directory(text).map(PathBuf::from), + )) + } + + fn event(&self, payload: &str, redactor: &Redactor) -> Result> { + let input: CursorHookInput = + serde_json::from_str(payload).context("parse the hook payload")?; + let Some(session) = input + .conversation_id + .clone() + .or_else(|| input.session_id.clone()) + .filter(|session| !session.is_empty()) + else { + return Ok(None); + }; + let Some(cwd) = input + .cwd + .clone() + .filter(|cwd| cwd.is_absolute()) + .or_else(|| input.workspace_roots.first().cloned()) + .filter(|cwd| cwd.is_absolute()) + else { + return Ok(None); + }; + let root = cwd.to_string_lossy().into_owned(); + let here = Self::string(input.tool_input.as_ref(), &["working_directory", "cwd"]) + .filter(|dir| Path::new(dir).is_absolute()) + .map_or_else(|| root.clone(), str::to_owned); + let model = input.model_id.as_deref().or(input.model.as_deref()); + let observer = Observer { + session: &session, + cwd: &root, + at: Timestamp::now(), + model, + redactor, + }; + let shell = Observer { + cwd: &here, + ..observer + }; + let duration_ms = input.duration.as_ref().and_then(Value::as_u64); + let is_shell = input.tool_name.as_deref() == Some(Self::SHELL); + let command = Self::string(input.tool_input.as_ref(), &["command"]).unwrap_or_default(); + let subagent = input.parent_tool_call_id.is_some(); + + let mut observed = Vec::new(); + let moment = match input.hook_event_name.as_str() { + "sessionStart" => Moment::SessionStart, + "beforeSubmitPrompt" => { + let Some(prompt) = input + .prompt + .as_deref() + .filter(|prompt| !prompt.trim().is_empty()) + else { + return Ok(None); + }; + if prompt.trim_start().starts_with(Reminder::PREFIX) { + return Ok(None); + } + observed.push(observer.prompt(prompt)); + Moment::Prompt(prompt.to_owned()) + } + "postToolUse" if is_shell => { + let result = Self::shell_result(input.tool_output.as_ref()); + let failed = result.exit_code.is_some_and(|code| code != 0); + if !subagent { + observed.push(shell.command( + Self::SHELL, + command, + Ran { + output: &result.output, + exit_code: result.exit_code, + failed, + interrupted: false, + duration_ms, + }, + )); + } + if failed { + Moment::CommandFailed(result.output) + } else { + Moment::ToolDone + } + } + "postToolUse" => { + observed.extend(Self::observe_tool(&input, &observer, false).filter(|_| !subagent)); + Moment::ToolDone + } + "postToolUseFailure" if is_shell => { + let error = input.error_message.clone().unwrap_or_default(); + let interrupted = input.is_interrupt + || input.failure_type.as_deref() == Some("permission_denied"); + if !subagent { + observed.push(shell.command( + Self::SHELL, + command, + Ran { + output: &error, + exit_code: None, + failed: true, + interrupted, + duration_ms, + }, + )); + } + if interrupted { + Moment::ToolDone + } else { + Moment::CommandFailed(error) + } + } + "afterFileEdit" => { + let Some(path) = input.file_path.as_deref().filter(|path| !path.is_empty()) else { + return Ok(None); + }; + let patch = Cursor::patch(&input.edits); + let edit = match input.edits.as_slice() { + [only] if only.old_string.is_empty() => Edit::Created { + content: &only.new_string, + }, + [] => Edit::Unknown, + _ => Edit::Patched { diff: &patch }, + }; + observed.push(observer.edit("Edit", path, edit, true)); + Moment::ToolDone + } + "stop" => Moment::TurnEnd { + continued: input.loop_count.is_some_and(|count| count > 0), + }, + "preCompact" => { + observed.push(observer.compaction(input.trigger.as_deref() != Some("manual"))); + Moment::Compacting + } + "sessionEnd" => Moment::SessionEnd, + _ => return Ok(None), + }; + Ok(Some(HookEvent { + name: input.hook_event_name, + session, + cwd, + transcript: None, + moment, + observed, + })) + } + + // `beforeSubmitPrompt` cannot add context, so prompt recall is never shown, and never + // recorded. + fn render(&self, event: &HookEvent, reply: Reply<'_>) -> Option { + match (&event.moment, reply) { + (Moment::CommandFailed(_), Reply::Recall(envelope)) => { + Some(json!({ "additional_context": envelope }).to_string()) + } + (Moment::TurnEnd { continued: false }, Reply::Remind(reason)) => { + Some(json!({ "followup_message": reason }).to_string()) + } + _ => None, + } + } + + fn detected(&self) -> bool { + Self::config().is_ok_and(|config| config.is_dir()) + } + + fn connect(&self, program: &Program) -> Result> { + Self::connect_at(&Self::config()?, program) + } + + fn disconnect(&self) -> Result> { + Self::disconnect_at(&Self::config()?) + } + + fn connected(&self) -> Result { + Self::connected_at(&Self::config()?) + } +} + +#[cfg(test)] +mod tests { + use std::{fs, process}; + + use trodden_capture::journal::Observed; + use trodden_core::trace::{EventKind, ToolOutcome}; + + use super::*; + + const ROOT: &str = "/home/dev/shop"; + + #[derive(Debug)] + struct Payload; + + impl Payload { + fn event(extra: Value) -> Option { + let mut payload = json!({ + "conversation_id": "6130c625-1b9e-4f7a-a1a2-0c9d2c1f4e11", + "generation_id": "b0f7d2e4", + "model": "claude-opus-4-7-thinking-max", + "cursor_version": "3.23.1", + "workspace_roots": [ROOT], + "user_email": null, + "transcript_path": null, + }); + payload + .as_object_mut() + .expect("object") + .extend(extra.as_object().expect("object").clone()); + Cursor + .event(&payload.to_string(), &Redactor::with_home("/home/dev")) + .expect("payload parses") + } + + fn moment(extra: Value) -> Option { + Self::event(extra).map(|event| event.moment) + } + + fn shell(command: &str, output: &str, cwd: &str) -> Value { + json!({"hook_event_name": "postToolUse", "tool_name": "Shell", + "tool_input": {"command": command, "working_directory": cwd}, + "tool_output": output, "tool_use_id": "toolu_1", "cwd": cwd, "duration": 5432}) + } + + fn rendered(moment: Moment, reply: Reply<'_>) -> Option { + let event = HookEvent { + name: String::new(), + session: "s".to_owned(), + cwd: PathBuf::from(ROOT), + transcript: None, + moment, + observed: Vec::new(), + }; + Cursor.render(&event, reply) + } + } + + #[derive(Debug)] + struct Scratch { + dir: PathBuf, + } + + impl Scratch { + fn new(name: &str) -> Self { + let dir = env::temp_dir().join(format!("trodden-cursor-{name}-{}", process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("scratch dir is writable"); + Self { dir } + } + + fn hooks(&self) -> Value { + serde_json::from_str( + &fs::read_to_string(self.dir.join("hooks.json")).expect("readable"), + ) + .expect("JSON") + } + + fn apply(changes: Vec) { + for change in changes { + change.apply().expect("change applies"); + } + } + } + + impl Drop for Scratch { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.dir); + } + } + + #[test] + fn prompts_are_journaled_but_never_shown() { + let event = Payload::event(json!({"hook_event_name": "beforeSubmitPrompt", + "prompt": "Fix the paging bug", "attachments": []})) + .expect("event"); + + assert_eq!( + event.moment, + Moment::Prompt("Fix the paging bug".to_owned()) + ); + assert_eq!(event.session, "6130c625-1b9e-4f7a-a1a2-0c9d2c1f4e11"); + assert_eq!(event.cwd, PathBuf::from(ROOT)); + assert!(matches!( + &event.observed[..], + [Observation { + observed: Observed::Prompt { .. }, + .. + }] + )); + assert_eq!( + event.observed[0].model.as_deref(), + Some("claude-opus-4-7-thinking-max") + ); + assert_eq!(Payload::rendered(event.moment, Reply::Recall("")), None); + assert_eq!( + Payload::moment(json!({"hook_event_name": "beforeSubmitPrompt", + "prompt": "Trodden: the procedure recalled for this task is checked with `npm test`"})), + None + ); + } + + #[test] + fn shell_failures_are_recalled_from_either_event() { + let failed = Payload::event(Payload::shell( + "npm test", + r#"{"exitCode":1,"stdout":"","stderr":"Error: Cannot find module 'left-pad'"}"#, + ROOT, + )) + .expect("event"); + let passed = Payload::moment(Payload::shell( + "npm test", + r#"{"exitCode":0,"stdout":"1 passing"}"#, + ROOT, + )); + let failure = |extra: Value| { + let mut payload = json!({"hook_event_name": "postToolUseFailure", "tool_name": "Shell", + "tool_input": {"command": "npm test"}, "cwd": ROOT, + "error_message": "Command failed: boom", "failure_type": "error"}); + payload + .as_object_mut() + .expect("object") + .extend(extra.as_object().expect("object").clone()); + Payload::moment(payload) + }; + + assert_eq!( + failed.moment, + Moment::CommandFailed("Error: Cannot find module 'left-pad'".to_owned()) + ); + let Observed::Call { + outcome, + duration_ms, + .. + } = &failed.observed[0].observed + else { + panic!("a call is journaled"); + }; + assert_eq!(*outcome, ToolOutcome::Failed { exit_code: Some(1) }); + assert_eq!(*duration_ms, Some(5432)); + assert_eq!(passed, Some(Moment::ToolDone)); + assert_eq!( + failure(json!({})), + Some(Moment::CommandFailed("Command failed: boom".to_owned())) + ); + assert_eq!( + failure(json!({"is_interrupt": true})), + Some(Moment::ToolDone) + ); + assert_eq!( + failure(json!({"failure_type": "permission_denied"})), + Some(Moment::ToolDone) + ); + + let rendered = + Payload::rendered(Moment::CommandFailed("e".to_owned()), Reply::Recall("")) + .expect("failure recall renders"); + assert_eq!( + serde_json::from_str::(&rendered).expect("JSON"), + json!({"additional_context": ""}) + ); + } + + #[test] + fn odd_tool_outputs_are_tolerated() { + assert_eq!( + Cursor::shell_result(Some(&json!("plain text"))), + ShellResult { + exit_code: None, + output: "plain text".to_owned() + } + ); + assert_eq!( + Cursor::shell_result(Some(&json!({"exit_code": 2, "output": "boom"}))), + ShellResult { + exit_code: Some(2), + output: "boom".to_owned() + } + ); + assert_eq!(Cursor::shell_result(None), ShellResult::default()); + assert_eq!( + Payload::moment(json!({"hook_event_name": "postToolUse", "tool_name": "Shell"})), + Some(Moment::ToolDone) + ); + } + + #[test] + fn a_session_journals_into_a_trace() { + let events = [ + json!({"hook_event_name": "beforeSubmitPrompt", "prompt": "Page 2 repeats the last product. Fix it."}), + json!({"hook_event_name": "postToolUse", "tool_name": "Read", "cwd": ROOT, + "tool_input": {"path": format!("{ROOT}/src/paginate.js")}, "tool_output": "{}"}), + json!({"hook_event_name": "afterFileEdit", "file_path": format!("{ROOT}/src/paginate.js"), + "edits": [{"old_string": "export function paginate(items) {\n return items.slice(start, end + 1);\n}", + "new_string": "export function paginate(items) {\n return items.slice(start, end);\n}"}, + {"old_string": "// todo", "new_string": "// paging\n// done"}]}), + json!({"hook_event_name": "postToolUse", "tool_name": "Write", "cwd": ROOT, + "tool_input": {"path": format!("{ROOT}/src/paginate.js")}}), + Payload::shell( + "npm test", + r#"{"exitCode":0,"stdout":"1 passing"}"#, + &format!("{ROOT}/web"), + ), + json!({"hook_event_name": "postToolUse", "tool_name": "Shell", "parent_tool_call_id": "toolu_0", + "tool_input": {"command": "ls"}, "tool_output": "{\"exitCode\":0}", "cwd": ROOT}), + json!({"hook_event_name": "preCompact", "trigger": "auto", "context_usage_percent": 91}), + json!({"hook_event_name": "stop", "status": "completed", "loop_count": 0}), + ]; + let mut journal = String::new(); + for event in events { + for observation in Payload::event(event).expect("event").observed { + journal.push_str(&observation.to_line().expect("line encodes")); + } + } + + let (trace, cwd) = Cursor + .parse(&journal, &Redactor::with_home("/home/dev")) + .expect("journal parses"); + let calls: Vec<_> = trace + .events + .iter() + .filter_map(|event| match &event.kind { + EventKind::ToolCall(call) => Some(call), + _ => None, + }) + .collect(); + + assert_eq!(cwd, Some(PathBuf::from(ROOT))); + assert_eq!(trace.harness.as_str(), "cursor"); + assert_eq!(calls.len(), 3, "{calls:?}"); + assert_eq!(calls[0].action, ToolAction::Read); + assert_eq!(calls[0].args.path.as_deref(), Some("src/paginate.js")); + assert_eq!( + ( + calls[1].changes[0].lines_added, + calls[1].changes[0].lines_removed + ), + (3, 2) + ); + assert_eq!(calls[2].args.command.as_deref(), Some("cd web && npm test")); + assert!(matches!( + trace.events.last().map(|event| &event.kind), + Some(EventKind::Compaction { automatic: true }) + )); + } + + #[test] + fn stops_continue_only_once() { + assert_eq!( + Payload::moment( + json!({"hook_event_name": "stop", "status": "completed", "loop_count": 0}) + ), + Some(Moment::TurnEnd { continued: false }) + ); + assert_eq!( + Payload::moment( + json!({"hook_event_name": "stop", "status": "completed", "loop_count": 1}) + ), + Some(Moment::TurnEnd { continued: true }) + ); + assert_eq!( + Payload::rendered( + Moment::TurnEnd { continued: false }, + Reply::Remind("run it") + ), + Some(r#"{"followup_message":"run it"}"#.to_owned()) + ); + assert_eq!( + Payload::rendered(Moment::TurnEnd { continued: true }, Reply::Remind("run it")), + None + ); + } + + #[test] + fn other_events_and_odd_payloads_are_ignored() { + assert_eq!( + Payload::moment( + json!({"hook_event_name": "sessionEnd", "session_id": "x", "reason": "completed"}) + ), + Some(Moment::SessionEnd) + ); + assert_eq!( + Payload::moment(json!({"hook_event_name": "sessionStart", "session_id": "x"})), + Some(Moment::SessionStart) + ); + assert_eq!( + Payload::moment(json!({"hook_event_name": "afterAgentResponse", "text": "done"})), + None + ); + assert_eq!( + Payload::moment(json!({"hook_event_name": "afterShellExecution", "command": "ls"})), + None + ); + assert_eq!( + Payload::moment(json!({"hook_event_name": "stop", "workspace_roots": []})), + None + ); + assert_eq!( + Payload::moment( + json!({"hook_event_name": "stop", "conversation_id": "", "workspace_roots": [ROOT]}) + ), + None + ); + assert!( + Cursor + .event("not json", &Redactor::with_home("/home/dev")) + .is_err() + ); + } + + #[test] + fn connect_keeps_other_hooks_and_round_trips() { + let scratch = Scratch::new("connect"); + fs::write( + scratch.dir.join("hooks.json"), + json!({"version": 1, "hooks": {"stop": [{"command": "afplay done.aiff"}]}}).to_string(), + ) + .expect("writable"); + let program = Program::at("/opt/trodden/bin/trodden"); + + Scratch::apply(Cursor::connect_at(&scratch.dir, &program).expect("connects")); + assert!( + Cursor::connect_at(&scratch.dir, &program) + .expect("connects") + .is_empty(), + "idempotent" + ); + assert!(Cursor::connected_at(&scratch.dir).expect("readable")); + let hooks = scratch.hooks(); + assert_eq!(hooks["version"], 1); + assert_eq!( + hooks["hooks"]["stop"][0], + json!({"command": "afplay done.aiff"}) + ); + assert_eq!( + hooks["hooks"]["stop"][1], + json!({"type": "command", "command": "/opt/trodden/bin/trodden hook cursor", "timeout": 5, "loop_limit": 1}) + ); + assert_eq!(hooks["hooks"]["postToolUseFailure"][0]["matcher"], "Shell"); + assert!(hooks["hooks"].get("afterShellExecution").is_none()); + + Scratch::apply(Cursor::disconnect_at(&scratch.dir).expect("disconnects")); + assert_eq!( + scratch.hooks(), + json!({"version": 1, "hooks": {"stop": [{"command": "afplay done.aiff"}]}}) + ); + assert!(!Cursor::connected_at(&scratch.dir).expect("readable")); + + let fresh = Scratch::new("fresh"); + Scratch::apply(Cursor::connect_at(&fresh.dir, &program).expect("connects")); + assert_eq!(fresh.hooks()["version"], 1); + } +} diff --git a/crates/trodden/src/harness/droid.rs b/crates/trodden/src/harness/droid.rs new file mode 100644 index 0000000..d79c74d --- /dev/null +++ b/crates/trodden/src/harness/droid.rs @@ -0,0 +1,565 @@ +use std::{ + env, fs, + path::{Path, PathBuf}, +}; + +use anyhow::{Context, Result}; +use serde_json::{Value, json}; +use trodden_capture::droid::Transcript; +use trodden_core::Trace; +use trodden_redact::Redactor; + +use super::{Agent, Change, HookEvent, Moment, Reply, claude_code::ClaudeHookInput}; +use crate::connect::{HookFile, Program}; + +#[derive(Debug)] +pub(crate) struct Droid; + +impl Droid { + const HARNESS: &str = "droid"; + + const SHELL: &str = "Execute"; + + const TIMEOUT_SECONDS: u64 = 5; + + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) const MANUAL_STEP: &str = "Restart any running droid sessions to load the hooks."; + + const EVENTS: &[(&str, Option<&str>)] = &[ + ("SessionStart", None), + ("UserPromptSubmit", None), + ("PostToolUse", Some(Self::SHELL)), + ("Stop", None), + ("PreCompact", None), + ("SessionEnd", None), + ]; + + fn config() -> Result { + let home = match env::var_os("FACTORY_HOME_OVERRIDE").filter(|home| !home.is_empty()) { + Some(home) => PathBuf::from(home), + None => env::home_dir().context("find the home directory")?, + }; + Ok(home.join(".factory")) + } + + fn hooks(config: &Path) -> PathBuf { + config.join("hooks.json") + } + + fn connect_at(config: &Path, program: &Program) -> Result> { + let mut file = HookFile::load(Self::hooks(config))?; + file.remove(&[], Self::HARNESS); + let command = program.hook(Self::HARNESS); + for (event, matcher) in Self::EVENTS { + let mut group = json!({"hooks": [{ + "type": "command", + "command": command, + "timeout": Self::TIMEOUT_SECONDS, + }]}); + if let Some(matcher) = matcher { + group["matcher"] = json!(matcher); + } + file.add(&[], event, group)?; + } + Ok(file.change()?.into_iter().collect()) + } + + fn disconnect_at(config: &Path) -> Result> { + let mut file = HookFile::load(Self::hooks(config))?; + file.remove(&[], Self::HARNESS); + Ok(file.change()?.into_iter().collect()) + } + + fn connected_at(config: &Path) -> Result { + Ok(HookFile::load(Self::hooks(config))?.contains(&[], Self::HARNESS)) + } + + fn transcripts_in(sessions: &Path) -> Result> { + let mut transcripts = Vec::new(); + let mut directories = vec![sessions.to_path_buf()]; + for entry in + fs::read_dir(sessions).with_context(|| format!("list {}", sessions.display()))? + { + let path = entry.context("read a session directory entry")?.path(); + if path.is_dir() { + directories.push(path); + } + } + for directory in directories { + let Ok(entries) = fs::read_dir(&directory) else { + continue; + }; + for path in entries.flatten().map(|entry| entry.path()) { + if path + .extension() + .is_some_and(|extension| extension == "jsonl") + && !Self::is_subagent(&path.with_extension("settings.json")) + { + transcripts.push(path); + } + } + } + transcripts.sort(); + Ok(transcripts) + } + + fn is_subagent(settings: &Path) -> bool { + Self::settings(settings).is_some_and(|settings| { + settings + .get("tags") + .and_then(Value::as_array) + .into_iter() + .flatten() + .any(|tag| tag.pointer("/metadata/callingSessionId").is_some()) + }) + } + + fn settings(path: &Path) -> Option { + serde_json::from_str(&fs::read_to_string(path).ok()?).ok() + } + + fn model(sessions: &Path, text: &str) -> Option { + let session = Transcript::session(text)?; + let name = format!("{session}.settings.json"); + let slug = + Transcript::working_directory(text).map(|cwd| cwd.replace(['/', '\\', ':'], "-")); + let mut candidates: Vec = slug + .map(|slug| sessions.join(slug).join(&name)) + .into_iter() + .collect(); + candidates.push(sessions.join(&name)); + if let Ok(entries) = fs::read_dir(sessions) { + candidates.extend(entries.flatten().map(|entry| entry.path().join(&name))); + } + candidates.into_iter().find_map(|path| { + Self::settings(&path)? + .get("model") + .and_then(Value::as_str) + .filter(|model| !model.is_empty()) + .map(str::to_owned) + }) + } + + fn parse_in( + sessions: Option<&Path>, + text: &str, + redactor: &Redactor, + ) -> Result<(Trace, Option)> { + let model = sessions.and_then(|sessions| Self::model(sessions, text)); + Ok(( + Transcript::parse(text, model.as_deref(), redactor)?, + Transcript::working_directory(text).map(PathBuf::from), + )) + } + + fn failure(response: Option<&Value>) -> Option { + match response? { + Value::String(text) => { + let code = Self::exit_code(text)?; + (code != 0).then(|| text.clone()) + } + Value::Object(fields) => { + let flag = |key: &str| fields.get(key).and_then(Value::as_bool); + if flag("interrupted") == Some(true) || flag("isInterrupt") == Some(true) { + return None; + } + let code = [ + "exitCode", + "exit_code", + "returnCode", + "return_code", + "code", + "status", + ] + .iter() + .find_map(|key| fields.get(*key).and_then(Value::as_i64)); + let error = fields + .get("error") + .and_then(Value::as_str) + .filter(|error| !error.is_empty()); + let output: Vec<&str> = ["stdout", "stderr", "output", "content", "result"] + .iter() + .filter_map(|key| fields.get(*key).and_then(Value::as_str)) + .chain(error) + .filter(|text| !text.trim().is_empty()) + .collect(); + let output = output.join("\n"); + let failed = match code { + Some(code) => code != 0, + None => { + flag("is_error") == Some(true) + || flag("isError") == Some(true) + || flag("success") == Some(false) + || error.is_some() + || Self::exit_code(&output).is_some_and(|code| code != 0) + } + }; + failed.then_some(output) + } + _ => None, + } + } + + fn exit_code(text: &str) -> Option { + let lines: Vec<&str> = text + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect(); + [lines.first(), lines.last()] + .into_iter() + .flatten() + .find_map(|line| { + let lower = line.to_ascii_lowercase(); + ["exit code:", "exit code", "exited with code", "exit status"] + .iter() + .find_map(|marker| { + let rest = &lower[lower.find(marker)? + marker.len()..]; + let digits: String = rest + .trim_start_matches([' ', ':', '=']) + .chars() + .take_while(|c| c.is_ascii_digit() || *c == '-') + .collect(); + digits.parse().ok() + }) + }) + } +} + +impl Agent for Droid { + fn title(&self) -> &'static str { + "Factory Droid" + } + + fn history(&self) -> Result> { + Ok(Some(Self::config()?.join("sessions"))) + } + + fn transcripts(&self, sessions: &Path) -> Result> { + Self::transcripts_in(sessions) + } + + fn parse(&self, text: &str, redactor: &Redactor) -> Result<(Trace, Option)> { + let sessions = Self::config().ok().map(|config| config.join("sessions")); + Self::parse_in(sessions.as_deref(), text, redactor) + } + + fn event(&self, payload: &str, _redactor: &Redactor) -> Result> { + let Some(input) = ClaudeHookInput::parse(payload)? else { + return Ok(None); + }; + let moment = match input.hook_event_name.as_str() { + "SessionStart" => Moment::SessionStart, + "UserPromptSubmit" => match &input.prompt { + Some(prompt) => Moment::Prompt(prompt.clone()), + None => return Ok(None), + }, + "PostToolUse" if input.tool_name.as_deref() == Some(Self::SHELL) => { + match Self::failure(input.tool_response.as_ref()) { + Some(error) => Moment::CommandFailed(error), + None => return Ok(None), + } + } + "PostToolUseFailure" => match input.failure(&[Self::SHELL]) { + Some(error) => Moment::CommandFailed(error), + None => return Ok(None), + }, + "Stop" => Moment::TurnEnd { + continued: input.stop_hook_active, + }, + "PreCompact" => Moment::Compacting, + "SessionEnd" => Moment::SessionEnd, + _ => return Ok(None), + }; + Ok(input.event(moment)) + } + + fn render(&self, event: &HookEvent, reply: Reply<'_>) -> Option { + match (&event.moment, reply) { + (Moment::Prompt(_), Reply::Recall(envelope)) => Some(envelope.to_owned()), + (Moment::CommandFailed(_), Reply::Recall(envelope)) => { + Some(ClaudeHookInput::recall_context("PostToolUse", envelope)) + } + (Moment::TurnEnd { .. }, Reply::Remind(reason)) => Some(ClaudeHookInput::block(reason)), + _ => None, + } + } + + fn detected(&self) -> bool { + Self::config().is_ok_and(|config| config.is_dir()) + } + + fn notice(&self) -> Option<&'static str> { + Some(Self::MANUAL_STEP) + } + + fn connect(&self, program: &Program) -> Result> { + Self::connect_at(&Self::config()?, program) + } + + fn disconnect(&self) -> Result> { + Self::disconnect_at(&Self::config()?) + } + + fn connected(&self) -> Result { + Self::connected_at(&Self::config()?) + } +} + +#[cfg(test)] +mod tests { + use std::process; + + use super::*; + + #[derive(Debug)] + struct Scratch { + dir: PathBuf, + } + + impl Scratch { + fn new(name: &str) -> Self { + let dir = env::temp_dir().join(format!("trodden-droid-{name}-{}", process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("scratch dir is writable"); + Self { dir } + } + + fn write(&self, path: &str, contents: &str) -> PathBuf { + let path = self.dir.join(path); + fs::create_dir_all(path.parent().expect("has a parent")).expect("dir is writable"); + fs::write(&path, contents).expect("file is writable"); + path + } + + fn apply(changes: Vec) { + for change in changes { + change.apply().expect("change applies"); + } + } + + fn hooks(&self) -> Value { + serde_json::from_str( + &fs::read_to_string(self.dir.join("hooks.json")).expect("readable"), + ) + .expect("JSON") + } + } + + impl Drop for Scratch { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.dir); + } + } + + #[derive(Debug)] + struct Payload; + + impl Payload { + fn moment(extra: Value) -> Option { + let mut payload = json!({"session_id": "6f0c", "cwd": "/home/dev/shop", + "transcript_path": "/home/dev/.factory/sessions/-home-dev-shop/6f0c.jsonl", + "permission_mode": "auto-low"}); + payload + .as_object_mut() + .expect("object") + .extend(extra.as_object().expect("object").clone()); + Droid + .event(&payload.to_string(), &Redactor::with_home("/home/dev")) + .expect("payload parses") + .map(|event| event.moment) + } + + fn event(moment: Moment) -> HookEvent { + HookEvent { + name: String::new(), + session: "6f0c".to_owned(), + cwd: PathBuf::from("/home/dev/shop"), + transcript: None, + moment, + observed: Vec::new(), + } + } + } + + #[test] + fn hook_events_map_to_moments() { + assert_eq!( + Payload::moment( + json!({"hook_event_name": "UserPromptSubmit", "prompt": "Fix paging", "has_images": false}) + ), + Some(Moment::Prompt("Fix paging".to_owned())) + ); + assert_eq!( + Payload::moment( + json!({"hook_event_name": "Stop", "stop_hook_active": true, "tool_execution_count": 3}) + ), + Some(Moment::TurnEnd { continued: true }) + ); + assert_eq!( + Payload::moment(json!({"hook_event_name": "PreCompact", "trigger": "auto"})), + Some(Moment::Compacting) + ); + assert_eq!( + Payload::moment(json!({"hook_event_name": "SessionEnd", "reason": "other"})), + Some(Moment::SessionEnd) + ); + assert_eq!( + Payload::moment(json!({"hook_event_name": "SessionStart", "source": "startup"})), + Some(Moment::SessionStart) + ); + assert_eq!( + Payload::moment(json!({"hook_event_name": "Notification"})), + None + ); + } + + #[test] + fn failed_executes_are_read_from_any_plausible_response() { + let post = |tool: &str, response: Value| { + Payload::moment(json!({"hook_event_name": "PostToolUse", "tool_name": tool, + "tool_input": {"command": "npm test"}, "tool_response": response})) + }; + let failed = |text: &str| Some(Moment::CommandFailed(text.to_owned())); + + assert_eq!( + post( + "Execute", + json!({"exitCode": 1, "stdout": "", "stderr": "Error: Cannot find module 'x'"}) + ), + failed("Error: Cannot find module 'x'") + ); + assert_eq!( + post("Execute", json!({"exit_code": 0, "stdout": "1 passing"})), + None + ); + assert_eq!( + post("Execute", json!({"success": false, "output": "boom"})), + failed("boom") + ); + assert_eq!( + post( + "Execute", + json!({"is_error": true, "content": "Exit code 2\nboom"}) + ), + failed("Exit code 2\nboom") + ); + assert_eq!( + post("Execute", json!("error: tests failed\nExit code: 101")), + failed("error: tests failed\nExit code: 101") + ); + assert_eq!(post("Execute", json!("all good")), None); + assert_eq!( + post("Execute", json!({"exitCode": 130, "interrupted": true})), + None + ); + assert_eq!(post("Edit", json!({"exitCode": 1, "stderr": "x"})), None); + assert_eq!(post("Execute", Value::Null), None); + } + + #[test] + fn replies_take_the_shape_droid_reads() { + assert_eq!( + Droid.render( + &Payload::event(Moment::Prompt("p".to_owned())), + Reply::Recall("") + ), + Some("".to_owned()) + ); + let failure: Value = serde_json::from_str( + &Droid + .render( + &Payload::event(Moment::CommandFailed("e".to_owned())), + Reply::Recall(""), + ) + .expect("failure recall renders"), + ) + .expect("JSON"); + assert_eq!( + failure["hookSpecificOutput"]["hookEventName"], + "PostToolUse" + ); + assert_eq!(failure["hookSpecificOutput"]["additionalContext"], ""); + assert_eq!( + Droid.render( + &Payload::event(Moment::TurnEnd { continued: false }), + Reply::Remind("run it") + ), + Some(r#"{"decision":"block","reason":"run it"}"#.to_owned()) + ); + } + + #[test] + fn connect_keeps_other_hooks_and_round_trips() { + let scratch = Scratch::new("connect"); + scratch.write( + "hooks.json", + &json!({"PostToolUse": [{"matcher": "Edit", "hooks": [{"type": "command", "command": "prettier --write"}]}]}) + .to_string(), + ); + let program = Program::at("/home/dev/.cargo/bin/trodden"); + + assert!(!Droid::connected_at(&scratch.dir).expect("readable")); + Scratch::apply(Droid::connect_at(&scratch.dir, &program).expect("connects")); + assert!( + Droid::connect_at(&scratch.dir, &program) + .expect("connects") + .is_empty(), + "idempotent" + ); + assert!(Droid::connected_at(&scratch.dir).expect("readable")); + + let hooks = scratch.hooks(); + assert_eq!(hooks["PostToolUse"][0]["matcher"], "Edit"); + assert_eq!(hooks["PostToolUse"][1]["matcher"], "Execute"); + assert_eq!( + hooks["Stop"][0]["hooks"][0], + json!({"type": "command", "command": "/home/dev/.cargo/bin/trodden hook droid", "timeout": 5}) + ); + assert!( + hooks.get("hooks").is_none(), + "user hooks are a bare event map" + ); + + Scratch::apply(Droid::disconnect_at(&scratch.dir).expect("disconnects")); + assert_eq!( + scratch.hooks(), + json!({"PostToolUse": [{"matcher": "Edit", "hooks": [{"type": "command", "command": "prettier --write"}]}]}) + ); + assert!(!Droid::connected_at(&scratch.dir).expect("readable")); + assert!(Droid::MANUAL_STEP.contains("Restart")); + } + + #[test] + fn backfill_finds_main_sessions_and_their_model() { + let scratch = Scratch::new("history"); + let header = json!({"type": "session_start", "id": "6f0c", "cwd": "/home/dev/shop"}); + let prompt = json!({"type": "message", "id": "m1", "timestamp": "2026-10-09T10:00:00Z", + "message": {"role": "user", "content": "Fix the paging bug"}}); + let text = format!("{header}\n{prompt}\n"); + let main = scratch.write("-home-dev-shop/6f0c.jsonl", &text); + scratch.write( + "-home-dev-shop/6f0c.settings.json", + r#"{"model": "claude-opus-4-5"}"#, + ); + scratch.write("-home-dev-shop/sub1.jsonl", &text); + scratch.write( + "-home-dev-shop/sub1.settings.json", + r#"{"tags": [{"name": "subagent", "metadata": {"callingSessionId": "6f0c", "callingToolUseId": "t1"}}]}"#, + ); + let legacy = scratch.write("legacy.jsonl", &text); + + assert_eq!( + Droid::transcripts_in(&scratch.dir).expect("listed"), + [main, legacy] + ); + let (trace, cwd) = + Droid::parse_in(Some(&scratch.dir), &text, &Redactor::with_home("/home/dev")) + .expect("parses"); + assert_eq!(trace.model.as_deref(), Some("claude-opus-4-5")); + assert_eq!(cwd, Some(PathBuf::from("/home/dev/shop"))); + let (trace, _) = + Droid::parse_in(None, &text, &Redactor::with_home("/home/dev")).expect("parses"); + assert_eq!(trace.model, None); + } +} diff --git a/crates/trodden/src/harness/gemini.rs b/crates/trodden/src/harness/gemini.rs new file mode 100644 index 0000000..2d9cf9e --- /dev/null +++ b/crates/trodden/src/harness/gemini.rs @@ -0,0 +1,614 @@ +use std::{ + env, fs, + io::{BufRead, BufReader}, + path::{Path, PathBuf}, +}; + +use anyhow::{Context, Result}; +use serde_json::{Value, json}; +use trodden_capture::{ + Reminder, + gemini::{ShellResult, Transcript}, +}; +use trodden_core::Trace; +use trodden_redact::Redactor; + +use super::{Agent, Change, HookEvent, Moment, Reply, claude_code::ClaudeHookInput}; +use crate::connect::{HookFile, Program}; + +#[derive(Debug)] +pub(crate) struct Gemini; + +impl Agent for Gemini { + fn title(&self) -> &'static str { + "Gemini CLI" + } + + fn history(&self) -> Result> { + Ok(Some(GeminiHome::locate()?.history())) + } + + fn transcripts(&self, history: &Path) -> Result> { + GeminiHome::transcripts(history) + } + + fn parse(&self, text: &str, redactor: &Redactor) -> Result<(Trace, Option)> { + let cwd = GeminiHome::locate() + .ok() + .and_then(|home| home.project_root(text)) + .or_else(|| GeminiHome::added_directory(text)); + let trace = Transcript::parse(text, cwd.as_deref().and_then(Path::to_str), redactor)?; + Ok((trace, cwd)) + } + + fn event(&self, payload: &str, _redactor: &Redactor) -> Result> { + let Some(input) = ClaudeHookInput::parse(payload)? else { + return Ok(None); + }; + let moment = match input.hook_event_name.as_str() { + "SessionStart" => Moment::SessionStart, + // An AfterAgent block reruns BeforeAgent with the reminder as the prompt. + "BeforeAgent" => match input.prompt.as_deref().map(str::trim) { + Some(prompt) if !prompt.is_empty() && !prompt.starts_with(Reminder::PREFIX) => { + Moment::Prompt(prompt.to_owned()) + } + _ => return Ok(None), + }, + "AfterTool" => match Self::failure(&input) { + Some(output) => Moment::CommandFailed(output), + None => return Ok(None), + }, + "AfterAgent" => Moment::TurnEnd { + continued: input.stop_hook_active, + }, + "PreCompress" => Moment::Compacting, + "SessionEnd" => Moment::SessionEnd, + _ => return Ok(None), + }; + Ok(input.event(moment)) + } + + fn render(&self, event: &HookEvent, reply: Reply<'_>) -> Option { + match (&event.moment, reply) { + (Moment::Prompt(_), Reply::Recall(envelope)) => { + Some(ClaudeHookInput::recall_context("BeforeAgent", envelope)) + } + (Moment::CommandFailed(_), Reply::Recall(envelope)) => { + Some(ClaudeHookInput::recall_context("AfterTool", envelope)) + } + (Moment::TurnEnd { .. }, Reply::Remind(reason)) => Some(ClaudeHookInput::block(reason)), + _ => None, + } + } + + fn detected(&self) -> bool { + GeminiHome::locate().is_ok_and(|home| home.detected()) + } + + fn connect(&self, program: &Program) -> Result> { + GeminiHome::locate()?.connect(program) + } + + fn disconnect(&self) -> Result> { + GeminiHome::locate()?.disconnect() + } + + fn connected(&self) -> Result { + GeminiHome::locate()?.connected() + } +} + +impl Gemini { + const SHELL: &str = "run_shell_command"; + + fn failure(input: &ClaudeHookInput) -> Option { + if input.tool_name.as_deref() != Some(Self::SHELL) { + return None; + } + let response = input.tool_response.as_ref()?; + let content = match response.get("llmContent") { + Some(Value::String(text)) => text.clone(), + Some(Value::Array(parts)) => parts + .iter() + .filter_map(|part| part.get("text").and_then(Value::as_str)) + .collect::>() + .join("\n"), + _ => String::new(), + }; + let shell = ShellResult::parse(&content); + if shell.interrupted { + return None; + } + let spawn_error = response + .get("error") + .and_then(|error| error.get("message")) + .and_then(Value::as_str); + match (shell.failed, spawn_error) { + (true, _) => Some(shell.output), + (false, Some(message)) => Some(message.to_owned()), + (false, None) => None, + } + } +} + +#[derive(Debug, Clone)] +struct GeminiHome { + dir: PathBuf, +} + +impl GeminiHome { + const EVENTS: &[(&str, Option<&str>)] = &[ + ("SessionStart", None), + ("BeforeAgent", None), + ("AfterTool", Some("^run_shell_command$")), + ("AfterAgent", None), + ("PreCompress", None), + ("SessionEnd", None), + ]; + + const TIMEOUT_MS: u64 = 5000; + + fn locate() -> Result { + let home = match env::var_os("GEMINI_CLI_HOME").filter(|home| !home.is_empty()) { + Some(home) => PathBuf::from(home), + None => env::home_dir().context("find the home directory")?, + }; + Ok(Self::at(home.join(".gemini"))) + } + + fn at(dir: impl Into) -> Self { + Self { dir: dir.into() } + } + + fn settings(&self) -> PathBuf { + self.dir.join("settings.json") + } + + fn history(&self) -> PathBuf { + self.dir.join("tmp") + } + + fn detected(&self) -> bool { + self.settings().is_file() || self.history().is_dir() + } + + fn transcripts(history: &Path) -> Result> { + let mut transcripts = Vec::new(); + for project in + fs::read_dir(history).with_context(|| format!("list {}", history.display()))? + { + let chats = project + .context("read a project directory entry")? + .path() + .join("chats"); + let Ok(entries) = fs::read_dir(&chats) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + let name = entry.file_name(); + let name = name.to_string_lossy(); + if !path.is_file() || !name.starts_with("session-") { + continue; + } + let jsonl = name.ends_with(".jsonl"); + let legacy = name.ends_with(".json") && !path.with_extension("jsonl").exists(); + if jsonl || legacy { + transcripts.push(path); + } + } + } + transcripts.sort(); + Ok(transcripts) + } + + fn project_root(&self, text: &str) -> Option { + let session = Transcript::session(text)?; + let short: String = session.chars().take(8).collect(); + let projects = fs::read_dir(self.history()).ok()?; + for project in projects.flatten() { + let project = project.path(); + let Ok(chats) = fs::read_dir(project.join("chats")) else { + continue; + }; + let holds = chats.flatten().any(|chat| { + let name = chat.file_name(); + let name = name.to_string_lossy(); + name.starts_with("session-") + && (name.ends_with(&format!("-{short}.jsonl")) + || name.ends_with(&format!("-{short}.json"))) + && Self::session_of(&chat.path()).is_none_or(|id| id == session) + }); + if !holds { + continue; + } + if let Some(root) = fs::read_to_string(project.join(".project_root")) + .ok() + .map(|root| PathBuf::from(root.trim())) + .filter(|root| root.is_absolute()) + .or_else(|| self.registered(&project)) + { + return Some(root); + } + } + None + } + + fn session_of(path: &Path) -> Option { + let file = fs::File::open(path).ok()?; + let mut first = String::new(); + BufReader::new(file).read_line(&mut first).ok()?; + serde_json::from_str::(&first) + .ok()? + .get("sessionId") + .and_then(Value::as_str) + .map(str::to_owned) + } + + fn registered(&self, project: &Path) -> Option { + let slug = project.file_name()?.to_str()?; + let text = fs::read_to_string(self.dir.join("projects.json")).ok()?; + let registry: Value = serde_json::from_str(&text).ok()?; + registry + .get("projects")? + .as_object()? + .iter() + .find(|(_, value)| value.as_str() == Some(slug)) + .map(|(root, _)| PathBuf::from(root)) + .filter(|root| root.is_absolute()) + } + + fn added_directory(text: &str) -> Option { + Transcript::directories(text) + .into_iter() + .map(PathBuf::from) + .find(|dir| dir.is_absolute()) + } + + fn load(&self) -> Result { + HookFile::load(self.settings()).context( + "load the Gemini CLI settings (Gemini CLI allows comments there, Trodden does not)", + ) + } + + // No `$` in the command: Gemini CLI expands environment variables in settings.json. + fn connect(&self, program: &Program) -> Result> { + let mut file = self.load()?; + file.remove(&["hooks"], "gemini"); + let command = program.hook("gemini"); + for (event, matcher) in Self::EVENTS { + let handler = json!({ + "type": "command", + "name": "trodden", + "command": command, + "timeout": Self::TIMEOUT_MS, + }); + let group = match matcher { + Some(matcher) => json!({"matcher": matcher, "hooks": [handler]}), + None => json!({"hooks": [handler]}), + }; + file.add(&["hooks"], event, group)?; + } + Ok(file.change()?.into_iter().collect()) + } + + fn disconnect(&self) -> Result> { + if !self.settings().exists() { + return Ok(Vec::new()); + } + let mut file = self.load()?; + file.remove(&["hooks"], "gemini"); + Ok(file.change()?.into_iter().collect()) + } + + fn connected(&self) -> Result { + if !self.settings().exists() { + return Ok(false); + } + Ok(self.load()?.contains(&["hooks"], "gemini")) + } +} + +#[cfg(test)] +mod tests { + use std::process; + + use super::*; + + #[derive(Debug)] + struct Scratch { + dir: PathBuf, + } + + impl Scratch { + fn new(name: &str) -> Self { + let dir = env::temp_dir().join(format!("trodden-gemini-{name}-{}", process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("scratch dir is writable"); + Self { dir } + } + + fn home(&self) -> GeminiHome { + GeminiHome::at(self.dir.join(".gemini")) + } + + fn apply(changes: Vec) { + for change in changes { + change.apply().expect("change applies"); + } + } + + fn write(path: &Path, text: &str) { + fs::create_dir_all(path.parent().expect("parent")).expect("dir is writable"); + fs::write(path, text).expect("file is writable"); + } + } + + impl Drop for Scratch { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.dir); + } + } + + #[derive(Debug)] + struct Payload; + + impl Payload { + fn moment(payload: Value) -> Option { + let mut base = json!({"session_id": "8f0c2a3e", "transcript_path": "/home/dev/.gemini/tmp/shop/chats/session-2026-10-09T10-12-8f0c2a3e.jsonl", + "cwd": "/home/dev/shop", "timestamp": "2026-10-09T10:12:31.402Z"}); + base.as_object_mut() + .expect("object") + .extend(payload.as_object().expect("object").clone()); + Gemini + .event(&base.to_string(), &Redactor::with_home("/home/dev")) + .expect("payload parses") + .map(|event| event.moment) + } + + fn shell(llm_content: &str) -> Option { + Self::moment( + json!({"hook_event_name": "AfterTool", "tool_name": "run_shell_command", + "tool_input": {"command": "cargo clippy"}, "tool_response": {"llmContent": llm_content, "returnDisplay": "x"}}), + ) + } + } + + #[test] + fn hook_events_map_to_moments() { + assert_eq!( + Payload::moment( + json!({"hook_event_name": "BeforeAgent", "prompt": "fix the paging bug"}) + ), + Some(Moment::Prompt("fix the paging bug".to_owned())) + ); + assert_eq!( + Payload::moment( + json!({"hook_event_name": "BeforeAgent", "prompt": "Trodden: the procedure recalled for this task is checked with `npm test`, which has not run since your last change."}) + ), + None + ); + assert_eq!( + Payload::shell( + "\nOutput: error: could not compile `shop`\nExit Code: 101\nProcess Group PGID: 5\n" + ), + Some(Moment::CommandFailed( + "error: could not compile `shop`".to_owned() + )) + ); + assert_eq!( + Payload::shell( + "\nOutput: ok\nProcess Group PGID: 5\n" + ), + None + ); + assert_eq!( + Payload::shell("Command was cancelled by user before it could complete."), + None + ); + assert_eq!( + Payload::moment( + json!({"hook_event_name": "AfterTool", "tool_name": "read_file", "tool_response": {"llmContent": "Exit Code: 1"}}) + ), + None + ); + assert_eq!( + Payload::moment( + json!({"hook_event_name": "AfterTool", "tool_name": "run_shell_command", "tool_response": {"llmContent": "Output: (empty)", "error": {"message": "spawn bash ENOENT", "type": "shell_execute_error"}}}) + ), + Some(Moment::CommandFailed("spawn bash ENOENT".to_owned())) + ); + assert_eq!( + Payload::moment( + json!({"hook_event_name": "AfterAgent", "prompt": "p", "prompt_response": "r", "stop_hook_active": true}) + ), + Some(Moment::TurnEnd { continued: true }) + ); + assert_eq!( + Payload::moment(json!({"hook_event_name": "PreCompress", "trigger": "auto"})), + Some(Moment::Compacting) + ); + assert_eq!( + Payload::moment(json!({"hook_event_name": "SessionEnd", "reason": "exit"})), + Some(Moment::SessionEnd) + ); + assert_eq!( + Payload::moment(json!({"hook_event_name": "SessionStart", "source": "startup"})), + Some(Moment::SessionStart) + ); + assert_eq!( + Payload::moment(json!({"hook_event_name": "BeforeModel"})), + None + ); + } + + #[test] + fn empty_transcript_paths_are_no_transcript() { + let event = Gemini + .event( + &json!({"session_id": "s", "transcript_path": "", "cwd": "/home/dev/shop", "hook_event_name": "AfterAgent"}).to_string(), + &Redactor::with_home("/home/dev"), + ) + .expect("parses") + .expect("event"); + + assert_eq!(event.transcript, None); + assert_eq!(event.session, "s"); + } + + #[test] + fn every_reply_is_json() { + let event = |moment| HookEvent { + name: String::new(), + session: "s".to_owned(), + cwd: PathBuf::from("/w"), + transcript: None, + moment, + observed: Vec::new(), + }; + let prompt = Gemini + .render( + &event(Moment::Prompt("p".to_owned())), + Reply::Recall(""), + ) + .expect("prompt recall renders"); + let prompt: Value = serde_json::from_str(&prompt).expect("JSON"); + assert_eq!(prompt["hookSpecificOutput"]["hookEventName"], "BeforeAgent"); + assert_eq!(prompt["hookSpecificOutput"]["additionalContext"], ""); + + let failure = Gemini + .render( + &event(Moment::CommandFailed("e".to_owned())), + Reply::Recall(""), + ) + .expect("failure recall renders"); + let failure: Value = serde_json::from_str(&failure).expect("JSON"); + assert_eq!(failure["hookSpecificOutput"]["hookEventName"], "AfterTool"); + + assert_eq!( + Gemini.render( + &event(Moment::TurnEnd { continued: false }), + Reply::Remind("run it") + ), + Some(r#"{"decision":"block","reason":"run it"}"#.to_owned()) + ); + assert_eq!( + Gemini.render(&event(Moment::SessionEnd), Reply::Recall("")), + None + ); + } + + #[test] + fn connect_round_trips_and_keeps_other_hooks() { + let scratch = Scratch::new("connect"); + let home = scratch.home(); + Scratch::write( + &home.settings(), + &json!({"theme": "dark", "hooks": {"AfterAgent": [{"hooks": [{"type": "command", "command": "notify-send done"}]}]}}).to_string(), + ); + let program = Program::at("/opt/bin/trodden"); + + assert!(!home.connected().expect("settings load")); + Scratch::apply(home.connect(&program).expect("connect")); + assert!(home.connected().expect("settings load")); + assert!(home.connect(&program).expect("connect again").is_empty()); + + let settings: Value = + serde_json::from_str(&fs::read_to_string(home.settings()).expect("read")) + .expect("JSON"); + assert_eq!(settings["theme"], "dark"); + assert_eq!( + settings["hooks"]["AfterTool"][0]["matcher"], + "^run_shell_command$" + ); + assert_eq!( + settings["hooks"]["AfterTool"][0]["hooks"][0]["timeout"], + 5000 + ); + assert_eq!( + settings["hooks"]["BeforeAgent"][0]["hooks"][0]["command"], + "/opt/bin/trodden hook gemini" + ); + assert_eq!( + settings["hooks"]["AfterAgent"] + .as_array() + .expect("list") + .len(), + 2 + ); + + Scratch::apply(home.disconnect().expect("disconnect")); + assert!(!home.connected().expect("settings load")); + let settings: Value = + serde_json::from_str(&fs::read_to_string(home.settings()).expect("read")) + .expect("JSON"); + assert_eq!( + settings, + json!({"theme": "dark", "hooks": {"AfterAgent": [{"hooks": [{"type": "command", "command": "notify-send done"}]}]}}) + ); + } + + #[test] + fn settings_with_comments_are_refused_clearly() { + let scratch = Scratch::new("comments"); + let home = scratch.home(); + Scratch::write( + &home.settings(), + "{\n // my theme\n \"theme\": \"dark\"\n}\n", + ); + + let error = home + .connect(&Program::at("/opt/bin/trodden")) + .expect_err("comments are refused"); + + let message = format!("{error:#}"); + assert!(message.contains("Gemini CLI settings"), "{message}"); + assert!(message.contains("add the hooks by hand"), "{message}"); + } + + #[test] + fn sessions_find_their_project_root() { + let scratch = Scratch::new("root"); + let home = scratch.home(); + let metadata = json!({"sessionId": "8f0c2a3e-6d1b", "projectHash": "h", "startTime": "2026-10-09T10:12:20Z"}).to_string(); + let shop = home.history().join("shop"); + Scratch::write(&shop.join(".project_root"), "/home/dev/shop\n"); + Scratch::write( + &shop.join("chats/session-2026-10-09T10-12-8f0c2a3e.jsonl"), + &format!("{metadata}\n"), + ); + Scratch::write(&shop.join("chats/8f0c2a3e-6d1b/sub.jsonl"), "{}\n"); + let other = home.history().join("blog"); + Scratch::write( + &other.join("chats/session-2026-10-01T09-00-11111111.json"), + "{\n}\n", + ); + Scratch::write( + &home.dir.join("projects.json"), + &json!({"projects": {"/home/dev/blog": "blog"}}).to_string(), + ); + + assert_eq!( + home.project_root(&metadata), + Some(PathBuf::from("/home/dev/shop")) + ); + let legacy = + json!({"sessionId": "11111111-aaaa", "projectHash": "h", "messages": []}).to_string(); + assert_eq!( + home.project_root(&legacy), + Some(PathBuf::from("/home/dev/blog")) + ); + assert_eq!( + home.project_root(&json!({"sessionId": "99999999", "projectHash": "h"}).to_string()), + None + ); + + let transcripts = GeminiHome::transcripts(&home.history()).expect("list"); + assert_eq!(transcripts.len(), 2, "{transcripts:?}"); + assert!( + transcripts + .iter() + .all(|path| path.parent().is_some_and(|dir| dir.ends_with("chats"))) + ); + } +} diff --git a/crates/trodden/src/harness/kimi.rs b/crates/trodden/src/harness/kimi.rs new file mode 100644 index 0000000..731d9fd --- /dev/null +++ b/crates/trodden/src/harness/kimi.rs @@ -0,0 +1,792 @@ +use std::{ + env, fs, + io::ErrorKind, + path::{Path, PathBuf}, +}; + +use anyhow::{Context, Result, bail}; +use jiff::Timestamp; +use serde::Deserialize; +use serde_json::{Value, json}; +use trodden_capture::{ + journal::{Edit, Journal, Observation, Observer, Ran}, + kimi::HARNESS, +}; +use trodden_core::{Trace, trace::ToolAction}; +use trodden_redact::Redactor; + +use super::{Agent, Change, HookEvent, Moment, Reply}; +use crate::connect::{HookFile, Program}; + +#[derive(Debug)] +pub(crate) struct Kimi; + +#[derive(Debug, Deserialize)] +struct KimiHookInput { + hook_event_name: String, + session_id: String, + #[serde(default)] + cwd: Option, + #[serde(default)] + prompt: Option, + #[serde(default)] + is_steer: bool, + #[serde(default)] + tool_name: Option, + #[serde(default)] + tool_input: Option, + #[serde(default)] + tool_output: Option, + #[serde(default)] + error: Option, + #[serde(default)] + trigger: Option, +} + +impl Kimi { + const SHELL: &str = "Bash"; +} + +impl Agent for Kimi { + fn title(&self) -> &'static str { + "Kimi Code" + } + + fn journaled(&self) -> bool { + true + } + + fn history(&self) -> Result> { + Ok(None) + } + + fn transcripts(&self, _history: &Path) -> Result> { + Ok(Vec::new()) + } + + fn parse(&self, text: &str, redactor: &Redactor) -> Result<(Trace, Option)> { + Ok(( + Journal::parse(text, HARNESS, redactor)?, + Journal::working_directory(text).map(PathBuf::from), + )) + } + + fn event(&self, payload: &str, redactor: &Redactor) -> Result> { + let input: KimiHookInput = + serde_json::from_str(payload).context("parse the Kimi Code hook payload")?; + let Some(cwd) = input.cwd.clone().filter(|cwd| cwd.is_absolute()) else { + return Ok(None); + }; + if input.session_id.is_empty() { + return Ok(None); + } + let cwd_text = cwd.to_string_lossy().into_owned(); + let observer = Observer { + session: &input.session_id, + cwd: &cwd_text, + at: Timestamp::now(), + model: None, + redactor, + }; + let (moment, observed) = match input.hook_event_name.as_str() { + "SessionStart" => (Moment::SessionStart, Vec::new()), + "UserPromptSubmit" => { + let Some(prompt) = input.prompt_text().filter(|_| !input.is_steer) else { + return Ok(None); + }; + let observed = vec![observer.prompt(&prompt)]; + (Moment::Prompt(prompt), observed) + } + "PostToolUse" => match input.observe(&observer, None) { + Some(observation) => (Moment::ToolDone, vec![observation]), + None => return Ok(None), + }, + "PostToolUseFailure" => { + let error = input.error_text(); + let Some(observation) = input.observe(&observer, Some(&error)) else { + return Ok(None); + }; + let moment = if input.tool_name.as_deref() == Some(Self::SHELL) + && !KimiHookInput::interrupted(&error) + { + Moment::CommandFailed(error) + } else { + Moment::ToolDone + }; + (moment, vec![observation]) + } + "Stop" => (Moment::TurnEnd { continued: false }, Vec::new()), + "PreCompact" => { + let automatic = input.trigger.as_deref() != Some("manual"); + (Moment::Compacting, vec![observer.compaction(automatic)]) + } + "SessionEnd" => (Moment::SessionEnd, Vec::new()), + _ => return Ok(None), + }; + Ok(Some(HookEvent { + name: input.hook_event_name, + session: input.session_id, + cwd, + transcript: None, + moment, + observed, + })) + } + + fn render(&self, event: &HookEvent, reply: Reply<'_>) -> Option { + match (&event.moment, reply) { + (Moment::Prompt(_), Reply::Recall(envelope)) => Some(envelope.to_owned()), + (Moment::TurnEnd { .. }, Reply::Remind(reason)) => Some( + json!({ + "hookSpecificOutput": { + "permissionDecision": "deny", + "permissionDecisionReason": reason, + } + }) + .to_string(), + ), + _ => None, + } + } + + fn detected(&self) -> bool { + KimiHome::locate().is_ok_and(|home| home.dir.is_dir()) + } + + fn connect(&self, program: &Program) -> Result> { + KimiHome::locate()?.connect(program) + } + + fn disconnect(&self) -> Result> { + KimiHome::locate()?.disconnect() + } + + fn connected(&self) -> Result { + KimiHome::locate()?.connected() + } +} + +impl KimiHookInput { + fn prompt_text(&self) -> Option { + let text = match self.prompt.as_ref()? { + Value::String(text) => text.clone(), + Value::Array(parts) => parts + .iter() + .filter(|part| { + part.get("type") + .and_then(Value::as_str) + .is_none_or(|kind| kind == "text") + }) + .filter_map(|part| part.get("text").and_then(Value::as_str)) + .collect::>() + .join("\n"), + _ => return None, + }; + let text = text.trim(); + (!text.is_empty()).then(|| text.to_owned()) + } + + fn error_text(&self) -> String { + match &self.error { + Some(Value::String(text)) => text.clone(), + Some(error) => error + .get("message") + .and_then(Value::as_str) + .map_or_else(|| error.to_string(), str::to_owned), + None => String::new(), + } + } + + fn exit_code(error: &str) -> Option { + let rest = error.rsplit_once("Command failed with exit code: ")?.1; + let digits: String = rest + .chars() + .take_while(|c| c.is_ascii_digit() || *c == '-') + .collect(); + digits.parse().ok() + } + + fn interrupted(error: &str) -> bool { + let error = error.trim_end(); + error.ends_with("Interrupted by user") + || error.starts_with("Aborted before command started") + || error.contains("Command killed by timeout") + || error.contains("was denied by permission policy") + || error.contains("rejected by the user") + } + + fn observe(&self, observer: &Observer<'_>, error: Option<&str>) -> Option { + let tool = self.tool_name.as_deref()?; + let input = self.tool_input.clone().unwrap_or_default(); + let field = |key: &str| input.get(key).and_then(Value::as_str); + let succeeded = error.is_none(); + let observation = match tool { + "Bash" => { + let command = field("command")?; + let output = error.or(self.tool_output.as_deref()).unwrap_or_default(); + let ran = Ran { + output, + exit_code: error.and_then(Self::exit_code), + failed: !succeeded, + interrupted: error.is_some_and(Self::interrupted), + duration_ms: None, + }; + match field("cwd").filter(|dir| Path::new(dir).is_absolute()) { + Some(dir) => Observer { + session: observer.session, + cwd: dir, + at: observer.at, + model: observer.model, + redactor: observer.redactor, + } + .command(tool, command, ran), + None => observer.command(tool, command, ran), + } + } + "Edit" => observer.edit( + tool, + field("path")?, + Edit::Replaced { + old: field("old_string").unwrap_or_default(), + new: field("new_string").unwrap_or_default(), + }, + succeeded, + ), + "Write" => { + let content = field("content").unwrap_or_default(); + let edit = if field("mode") == Some("append") { + Edit::Replaced { + old: "", + new: content, + } + } else { + Edit::Created { content } + }; + observer.edit(tool, field("path")?, edit, succeeded) + } + "Read" => observer.read(tool, field("path")?), + "Grep" | "Glob" => observer.search(tool, field("pattern"), field("path")), + "FetchURL" => observer.fetch(tool, field("url"), None), + "WebSearch" => observer.fetch(tool, None, field("query")), + "Agent" => observer.other(tool, ToolAction::Delegate), + _ => return None, + }; + Some(observation) + } +} + +#[derive(Debug, Clone)] +struct KimiHome { + dir: PathBuf, +} + +impl KimiHome { + const TOOLS: &str = "^(Bash|Edit|Write|Read|Grep|Glob|FetchURL|WebSearch|Agent)$"; + + const EVENTS: &[(&str, Option<&str>)] = &[ + ("SessionStart", None), + ("UserPromptSubmit", None), + ("PostToolUse", Some(Self::TOOLS)), + ("PostToolUseFailure", Some(Self::TOOLS)), + ("Stop", None), + ("PreCompact", None), + ("SessionEnd", None), + ]; + + const TIMEOUT_SECONDS: u64 = 5; + + fn locate() -> Result { + let dir = match env::var_os("KIMI_CODE_HOME").filter(|dir| !dir.is_empty()) { + Some(dir) => PathBuf::from(dir), + None => env::home_dir() + .context("find the home directory")? + .join(".kimi-code"), + }; + Ok(Self { dir }) + } + + fn config(&self) -> PathBuf { + self.dir.join("config.toml") + } + + fn read(&self) -> Result> { + let path = self.config(); + match fs::read_to_string(&path) { + Ok(text) => Ok(Some(text)), + Err(error) if error.kind() == ErrorKind::NotFound => Ok(None), + Err(error) => Err(error).with_context(|| format!("read {}", path.display())), + } + } + + fn connect(&self, program: &Program) -> Result> { + let original = self.read()?; + let text = original.as_deref().unwrap_or_default(); + if TomlHooks::inline(text) { + bail!( + "{} sets `hooks` inline; add Trodden's hooks there by hand", + self.config().display() + ); + } + let mut updated = TomlHooks::without_ours(text); + let command = TomlHooks::quoted(&program.hook("kimi")); + if !updated.is_empty() { + if !updated.ends_with('\n') { + updated.push('\n'); + } + if !updated.ends_with("\n\n") { + updated.push('\n'); + } + } + let blocks: Vec = Self::EVENTS + .iter() + .map(|(event, matcher)| { + let matcher = matcher + .map(|matcher| format!("matcher = {}\n", TomlHooks::quoted(matcher))) + .unwrap_or_default(); + format!( + "[[hooks]]\nevent = \"{event}\"\n{matcher}command = {command}\ntimeout = {}\n", + Self::TIMEOUT_SECONDS + ) + }) + .collect(); + updated.push_str(&blocks.join("\n")); + Ok(self.change(original.as_deref(), updated)) + } + + fn disconnect(&self) -> Result> { + let Some(original) = self.read()? else { + return Ok(Vec::new()); + }; + let updated = TomlHooks::without_ours(&original); + Ok(self.change(Some(&original), updated)) + } + + fn connected(&self) -> Result { + Ok(self + .read()? + .is_some_and(|text| TomlHooks::contains_ours(&text))) + } + + fn change(&self, original: Option<&str>, updated: String) -> Vec { + if original == Some(updated.as_str()) || (original.is_none() && updated.is_empty()) { + return Vec::new(); + } + vec![Change::write(self.config(), updated)] + } +} + +// No TOML parser: Trodden's `[[hooks]]` tables are found and removed line by line. +#[derive(Debug)] +struct TomlHooks; + +impl TomlHooks { + fn is_header(line: &str) -> bool { + line.trim_start().starts_with('[') + } + + fn is_hooks_header(line: &str) -> bool { + line.trim_start() + .strip_prefix("[[") + .and_then(|rest| rest.trim_start().strip_prefix("hooks")) + .is_some_and(|rest| rest.trim_start().starts_with("]]")) + } + + fn inline(text: &str) -> bool { + text.lines() + .take_while(|line| !Self::is_header(line)) + .any(|line| { + line.trim_start() + .strip_prefix("hooks") + .is_some_and(|rest| rest.trim_start().starts_with('=')) + }) + } + + fn tables(text: &str) -> Vec> { + let mut tables: Vec> = vec![Vec::new()]; + for line in text.split_inclusive('\n') { + if Self::is_header(line) { + tables.push(Vec::new()); + } + if let Some(table) = tables.last_mut() { + table.push(line); + } + } + tables + } + + fn is_ours(table: &[&str]) -> bool { + table + .first() + .is_some_and(|header| Self::is_hooks_header(header)) + && table.iter().any(|line| { + line.trim_start() + .strip_prefix("command") + .is_some_and(|rest| rest.trim_start().starts_with('=')) + && HookFile::is_ours(line, "kimi") + }) + } + + fn contains_ours(text: &str) -> bool { + Self::tables(text).iter().any(|table| Self::is_ours(table)) + } + + fn without_ours(text: &str) -> String { + let tables = Self::tables(text); + if !tables.iter().any(|table| Self::is_ours(table)) { + return text.to_owned(); + } + let kept: String = tables + .iter() + .filter(|table| !Self::is_ours(table)) + .flatten() + .copied() + .collect(); + Self::tidy(&kept) + } + + fn tidy(text: &str) -> String { + let mut tidy = String::with_capacity(text.len()); + let mut blank = 0; + for line in text.split_inclusive('\n') { + if line.trim().is_empty() { + blank += 1; + if blank > 1 { + continue; + } + } else { + blank = 0; + } + tidy.push_str(line); + } + let trimmed = tidy.trim_end(); + if trimmed.is_empty() { + String::new() + } else { + format!("{trimmed}\n") + } + } + + fn quoted(text: &str) -> String { + let mut quoted = String::with_capacity(text.len() + 2); + quoted.push('"'); + for c in text.chars() { + match c { + '"' => quoted.push_str("\\\""), + '\\' => quoted.push_str("\\\\"), + '\n' => quoted.push_str("\\n"), + '\t' => quoted.push_str("\\t"), + c if c.is_control() => quoted.push_str(&format!("\\u{:04X}", u32::from(c))), + c => quoted.push(c), + } + } + quoted.push('"'); + quoted + } +} + +#[cfg(test)] +mod tests { + use std::process; + + use trodden_core::trace::{EventKind, ToolOutcome}; + + use super::*; + + #[derive(Debug)] + struct Scratch { + dir: PathBuf, + } + + impl Scratch { + fn new(name: &str) -> Self { + let dir = env::temp_dir().join(format!("trodden-kimi-{name}-{}", process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("scratch dir is writable"); + Self { dir } + } + + fn home(&self) -> KimiHome { + KimiHome { + dir: self.dir.join(".kimi-code"), + } + } + + fn apply(changes: Vec) { + for change in changes { + change.apply().expect("change applies"); + } + } + } + + impl Drop for Scratch { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.dir); + } + } + + #[derive(Debug)] + struct Payload; + + impl Payload { + fn event(payload: Value) -> Option { + let mut base = json!({"session_id": "session_abc", "session_title": "Fix paging", "client_type": "kimi_code_cli", "cwd": "/home/dev/shop"}); + base.as_object_mut() + .expect("object") + .extend(payload.as_object().expect("object").clone()); + Kimi.event(&base.to_string(), &Redactor::with_home("/home/dev")) + .expect("payload parses") + } + + fn journal(payloads: &[Value]) -> Trace { + let mut text = String::new(); + for payload in payloads { + for observation in Self::event(payload.clone()).expect("event").observed { + text.push_str(&observation.to_line().expect("line")); + } + } + Kimi.parse(&text, &Redactor::with_home("/home/dev")) + .expect("journal parses") + .0 + } + } + + #[test] + fn prompts_recall_from_text_or_content_parts() { + let event = Payload::event(json!({"hook_event_name": "UserPromptSubmit", "prompt": [{"type": "text", "text": "fix the paging bug"}], "is_steer": false})) + .expect("event"); + assert_eq!( + event.moment, + Moment::Prompt("fix the paging bug".to_owned()) + ); + assert_eq!(event.observed.len(), 1); + assert_eq!(event.transcript, None); + + assert_eq!( + Payload::event(json!({"hook_event_name": "UserPromptSubmit", "prompt": "fix it"})) + .map(|e| e.moment), + Some(Moment::Prompt("fix it".to_owned())) + ); + assert!(Payload::event(json!({"hook_event_name": "UserPromptSubmit", "prompt": "also this", "is_steer": true})).is_none()); + assert!( + Payload::event( + json!({"hook_event_name": "UserPromptSubmit", "prompt": [{"type": "image_url"}]}) + ) + .is_none() + ); + } + + #[test] + fn tool_events_are_journaled() { + let failed = Payload::event(json!({"hook_event_name": "PostToolUseFailure", "tool_name": "Bash", "tool_input": {"command": "npm test"}, "tool_call_id": "c1", + "error": {"code": "internal", "message": "1 failing\nCommand failed with exit code: 1.", "retryable": false}})) + .expect("event"); + assert_eq!( + failed.moment, + Moment::CommandFailed("1 failing\nCommand failed with exit code: 1.".to_owned()) + ); + assert_eq!(failed.observed.len(), 1); + + let interrupted = Payload::event(json!({"hook_event_name": "PostToolUseFailure", "tool_name": "Bash", "tool_input": {"command": "sleep 9"}, + "error": {"message": "Interrupted by user"}})) + .expect("event"); + assert_eq!(interrupted.moment, Moment::ToolDone); + + let done = Payload::event(json!({"hook_event_name": "PostToolUse", "tool_name": "Read", "tool_input": {"path": "src/a.js"}, "tool_output": "x"})) + .expect("event"); + assert_eq!(done.moment, Moment::ToolDone); + assert!( + Payload::event( + json!({"hook_event_name": "PostToolUse", "tool_name": "mcp__x", "tool_input": {}}) + ) + .is_none() + ); + assert!( + Payload::event( + json!({"hook_event_name": "PostToolUse", "tool_name": "Bash", "tool_input": {}}) + ) + .is_none() + ); + + assert_eq!( + Payload::event(json!({"hook_event_name": "Stop", "stop_hook_active": false})) + .map(|e| e.moment), + Some(Moment::TurnEnd { continued: false }) + ); + let compacting = Payload::event( + json!({"hook_event_name": "PreCompact", "trigger": "auto", "token_count": 9}), + ) + .expect("event"); + assert_eq!( + (compacting.moment, compacting.observed.len()), + (Moment::Compacting, 1) + ); + assert_eq!( + Payload::event(json!({"hook_event_name": "SessionEnd", "reason": "exit"})) + .map(|e| e.moment), + Some(Moment::SessionEnd) + ); + assert_eq!( + Payload::event(json!({"hook_event_name": "SessionStart", "source": "startup"})) + .map(|e| e.moment), + Some(Moment::SessionStart) + ); + assert!(Payload::event(json!({"hook_event_name": "Notification"})).is_none()); + assert!( + Kimi.event( + &json!({"hook_event_name": "Stop", "session_id": "s", "cwd": "relative"}) + .to_string(), + &Redactor::with_home("/home/dev") + ) + .expect("parses") + .is_none() + ); + } + + #[test] + fn the_journal_becomes_a_trace() { + let trace = Payload::journal(&[ + json!({"hook_event_name": "UserPromptSubmit", "prompt": [{"type": "text", "text": "fix the paging bug"}]}), + json!({"hook_event_name": "PostToolUseFailure", "tool_name": "Bash", "tool_input": {"command": "npm test"}, + "error": {"message": "AssertionError: expected 3 to equal 2\nCommand failed with exit code: 1."}}), + json!({"hook_event_name": "PostToolUse", "tool_name": "Edit", "tool_input": {"path": "/home/dev/shop/src/paginate.js", "old_string": "a\n+ 1", "new_string": "a\n"}, "tool_output": "ok"}), + json!({"hook_event_name": "PostToolUse", "tool_name": "Bash", "tool_input": {"command": "npm test", "cwd": "/home/dev/shop/web"}, "tool_output": "3 passing"}), + json!({"hook_event_name": "PreCompact", "trigger": "manual"}), + ]); + let kinds: Vec = trace + .events + .iter() + .map(|event| match &event.kind { + EventKind::Prompt { summary } => format!("prompt {summary}"), + EventKind::ToolCall(call) => format!( + "{} {:?} {:?} {:?}", + call.tool, call.args.command, call.args.path, call.outcome + ), + EventKind::Compaction { automatic } => format!("compaction {automatic}"), + _ => "other".to_owned(), + }) + .collect(); + + assert_eq!(trace.session.as_str(), "session_abc"); + assert_eq!(trace.harness.as_str(), "kimi"); + assert_eq!( + kinds, + [ + "prompt fix the paging bug".to_owned(), + format!( + "Bash Some(\"npm test\") None {:?}", + ToolOutcome::Failed { exit_code: Some(1) } + ), + format!( + "Edit None Some(\"src/paginate.js\") {:?}", + ToolOutcome::Succeeded + ), + format!( + "Bash Some(\"cd web && npm test\") None {:?}", + ToolOutcome::Succeeded + ), + "compaction false".to_owned(), + ] + ); + let EventKind::ToolCall(edit) = &trace.events[2].kind else { + panic!("an edit"); + }; + assert_eq!( + (edit.changes[0].lines_added, edit.changes[0].lines_removed), + (0, 1) + ); + } + + #[test] + fn replies_take_the_shape_kimi_reads() { + let event = |moment| HookEvent { + name: String::new(), + session: "s".to_owned(), + cwd: PathBuf::from("/w"), + transcript: None, + moment, + observed: Vec::new(), + }; + + assert_eq!( + Kimi.render( + &event(Moment::Prompt("p".to_owned())), + Reply::Recall("") + ), + Some("".to_owned()) + ); + assert_eq!( + Kimi.render( + &event(Moment::CommandFailed("e".to_owned())), + Reply::Recall("") + ), + None + ); + let stop: Value = serde_json::from_str( + &Kimi + .render( + &event(Moment::TurnEnd { continued: false }), + Reply::Remind("run it"), + ) + .expect("renders"), + ) + .expect("JSON"); + assert_eq!(stop["hookSpecificOutput"]["permissionDecision"], "deny"); + assert_eq!( + stop["hookSpecificOutput"]["permissionDecisionReason"], + "run it" + ); + } + + #[test] + fn connect_round_trips_and_keeps_other_tables() { + let scratch = Scratch::new("connect"); + let home = scratch.home(); + fs::create_dir_all(&home.dir).expect("dir"); + let user = "default_model = \"kimi-k2\"\n\n[[hooks]]\nevent = \"Notification\"\nmatcher = \"task\\\\.completed\"\ncommand = \"terminal-notifier -message done\"\n\n[mcp]\nstartup_timeout_ms = 3000\n"; + fs::write(home.config(), user).expect("written"); + let program = Program::at("/opt/bin/trodden"); + + assert!(!home.connected().expect("config reads")); + Scratch::apply(home.connect(&program).expect("connect")); + assert!(home.connected().expect("config reads")); + assert!(home.connect(&program).expect("connect again").is_empty()); + let text = fs::read_to_string(home.config()).expect("read"); + assert!(text.starts_with(user), "{text}"); + assert!(text.contains("[[hooks]]\nevent = \"PostToolUse\"\nmatcher = \"^(Bash|Edit|Write|Read|Grep|Glob|FetchURL|WebSearch|Agent)$\"\ncommand = \"/opt/bin/trodden hook kimi\"\ntimeout = 5\n"), "{text}"); + assert_eq!(text.matches("[[hooks]]").count(), 8); + + Scratch::apply(home.disconnect().expect("disconnect")); + assert!(!home.connected().expect("config reads")); + assert_eq!(fs::read_to_string(home.config()).expect("read"), user); + } + + #[test] + fn a_missing_config_is_created_and_inline_hooks_are_refused() { + let scratch = Scratch::new("fresh"); + let home = scratch.home(); + + assert!(home.disconnect().expect("nothing to remove").is_empty()); + let changes = home + .connect(&Program::at("/Users/dev/Application Support/trodden")) + .expect("connect"); + let text = changes[0].contents.clone().expect("contents"); + assert!(text.starts_with("[[hooks]]\nevent = \"SessionStart\"\ncommand = \"'/Users/dev/Application Support/trodden' hook kimi\"\n"), "{text}"); + + fs::create_dir_all(&home.dir).expect("dir"); + fs::write(home.config(), "hooks = []\n").expect("written"); + let error = home + .connect(&Program::at("/opt/bin/trodden")) + .expect_err("inline hooks are refused"); + assert!(format!("{error:#}").contains("by hand")); + } + + #[test] + fn toml_strings_are_escaped() { + assert_eq!( + TomlHooks::quoted(r#"C:\bin\trodden "x""#), + r#""C:\\bin\\trodden \"x\"""# + ); + assert!(TomlHooks::is_hooks_header(" [[ hooks ]] # ours\n")); + assert!(!TomlHooks::is_hooks_header("[hooks_extra]\n")); + } +} diff --git a/crates/trodden/src/harness/opencode.rs b/crates/trodden/src/harness/opencode.rs new file mode 100644 index 0000000..81ff04a --- /dev/null +++ b/crates/trodden/src/harness/opencode.rs @@ -0,0 +1,825 @@ +use std::{ + env, fs, + path::{Path, PathBuf}, +}; + +use anyhow::{Context, Result}; +use jiff::Timestamp; +use serde::Deserialize; +use serde_json::Value; +use trodden_capture::journal::{Edit, Journal, Observation, Observer, Ran}; +use trodden_core::{Trace, trace::ToolAction}; +use trodden_redact::Redactor; + +use super::{Agent, Change, HookEvent, Moment, Reply}; +use crate::connect::Program; + +#[derive(Debug, Deserialize)] +struct OpenCodeHookInput { + event: String, + session: String, + cwd: PathBuf, + #[serde(default)] + prompt: Option, + #[serde(default)] + model: Option, + #[serde(default)] + tool: Option, + #[serde(default)] + args: Value, + #[serde(default)] + output: Option, + #[serde(default)] + exit: Option, + #[serde(default)] + diff: Option, + #[serde(default)] + exists: Option, +} + +#[derive(Debug)] +pub(crate) struct OpenCode { + title: &'static str, + harness: &'static str, + app: &'static str, + notice: &'static str, +} + +impl OpenCode { + pub(crate) const OPENCODE: Self = Self { + title: "OpenCode", + harness: "opencode", + app: "opencode", + notice: "Restart OpenCode to load the plugin.", + }; + + pub(crate) const KILO: Self = Self { + title: "Kilo Code", + harness: "kilo", + app: "kilo", + notice: "Restart Kilo Code to load the plugin.", + }; + + const PLUGIN_FILE: &str = "trodden.js"; + + const MARKER: &str = "Written by `trodden connect"; + + const BOOKKEEPING_TOOLS: &[&str] = &[ + "todowrite", + "todoread", + "question", + "skill", + "lsp", + "invalid", + ]; + + const PLUGIN_DIRS: &[&str] = &["plugins", "plugin"]; + + // Every hook body is guarded and every call bounded, so a slow or missing Trodden never + // breaks the agent. + const PLUGIN: &str = r#"// Written by `trodden connect __AGENT__`; `trodden disconnect __AGENT__` removes it. +import { spawn } from "node:child_process" + +const PROGRAM = __PROGRAM__ +const AGENT = __AGENT_JSON__ +const TIMEOUT_MS = 5000 +const REPLY_CHARS = 64 * 1024 +const OUTPUT_CHARS = 64 * 1024 +const ARG_CHARS = 256 * 1024 +const ARGS = ["command", "workdir", "filePath", "oldString", "newString", "content", "patchText", "pattern", "path", "include", "url", "query", "description", "subagent_type"] +const BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + +let lastTime = 0 +let counter = 0 + +const partId = () => { + const now = Date.now() + if (now !== lastTime) { + lastTime = now + counter = 0 + } + counter += 1 + const time = (BigInt(now) * 0x1000n + BigInt(counter)).toString(16).padStart(12, "0").slice(-12) + let random = "" + for (let i = 0; i < 14; i += 1) random += BASE62[Math.floor(Math.random() * 62)] + return "prt_" + time + random +} + +const clip = (text, chars, tail) => { + if (typeof text !== "string" || text.length <= chars) return text + return tail ? text.slice(-chars) : text.slice(0, chars) +} + +const pick = (args) => { + const picked = {} + if (!args || typeof args !== "object") return picked + for (const key of ARGS) { + if (args[key] !== undefined) picked[key] = clip(args[key], ARG_CHARS, false) + } + return picked +} + +const run = (payload) => + new Promise((resolve) => { + let child + let reply = "" + let done = false + const finish = (text) => { + if (done) return + done = true + clearTimeout(timer) + resolve(text) + } + const timer = setTimeout(() => { + try { + child?.kill() + child?.stdout?.destroy() + child?.unref() + } catch {} + finish("") + }, TIMEOUT_MS) + try { + child = spawn(PROGRAM, ["hook", AGENT], { stdio: ["pipe", "pipe", "ignore"], windowsHide: true }) + child.on("error", () => finish("")) + child.stdout.setEncoding("utf8") + child.stdout.on("data", (chunk) => { + if (reply.length < REPLY_CHARS) reply += chunk + }) + child.on("close", (code) => finish(code === 0 ? reply.trim() : "")) + child.stdin.on("error", () => {}) + child.stdin.end(JSON.stringify(payload)) + } catch { + finish("") + } + }) + +const guard = + (hook) => + async (...args) => { + try { + await hook(...args) + } catch {} + } + +export const TroddenPlugin = async ({ directory }) => { + const children = new Set() + const base = (session) => ({ session, cwd: directory }) + return { + event: guard(async ({ event }) => { + const properties = event?.properties ?? {} + if (event?.type === "session.created") { + const info = properties.info ?? {} + if (info.parentID) { + children.add(info.id ?? properties.sessionID) + return + } + void run({ event: "session_start", ...base(info.id ?? properties.sessionID) }) + } else if (event?.type === "session.status" && properties.status?.type === "idle") { + if (!children.has(properties.sessionID)) void run({ event: "turn_end", ...base(properties.sessionID) }) + } + }), + "chat.message": guard(async (input, output) => { + if (children.has(input.sessionID)) return + const prompt = output.parts + .filter((part) => part.type === "text" && !part.synthetic && !part.ignored) + .map((part) => part.text) + .join("\n") + if (!prompt.trim()) return + const model = input.model?.modelID ?? output.message?.model?.modelID + const reply = await run({ event: "prompt", ...base(input.sessionID), prompt, model }) + if (!reply) return + output.parts.push({ + id: partId(), + sessionID: input.sessionID, + messageID: output.message.id, + type: "text", + text: reply, + synthetic: true, + }) + }), + "tool.execute.after": guard(async (input, output) => { + if (children.has(input.sessionID)) return + const metadata = output?.metadata ?? {} + const reply = await run({ + event: "tool", + ...base(input.sessionID), + tool: input.tool, + args: pick(input.args), + output: clip(output?.output, OUTPUT_CHARS, true), + exit: typeof metadata.exit === "number" ? metadata.exit : null, + diff: clip(metadata.diff, ARG_CHARS, false), + exists: typeof metadata.exists === "boolean" ? metadata.exists : undefined, + }) + if (reply && typeof output.output === "string") output.output += "\n\n" + reply + }), + "experimental.session.compacting": guard(async (input) => { + if (!children.has(input.sessionID)) void run({ event: "compacting", ...base(input.sessionID) }) + }), + } +} +"#; + + fn config(&self) -> Result { + let base = match env::var_os("XDG_CONFIG_HOME").filter(|dir| !dir.is_empty()) { + Some(dir) => PathBuf::from(dir), + None => env::home_dir() + .context("find the home directory")? + .join(".config"), + }; + Ok(base.join(self.app)) + } + + fn data(&self) -> Result { + let base = match env::var_os("XDG_DATA_HOME").filter(|dir| !dir.is_empty()) { + Some(dir) => PathBuf::from(dir), + None => env::home_dir() + .context("find the home directory")? + .join(".local") + .join("share"), + }; + Ok(base.join(self.app)) + } + + fn plugin(&self, program: &Program) -> Result { + let path = serde_json::to_string(&program.path().to_string_lossy()) + .context("encode the trodden path")?; + let agent = serde_json::to_string(self.harness).context("encode the agent name")?; + Ok(Self::PLUGIN + .replace("__PROGRAM__", &path) + .replace("__AGENT_JSON__", &agent) + .replace("__AGENT__", self.harness)) + } + + fn connect_at(&self, config: &Path, program: &Program) -> Result> { + let mut changes = self.disconnect_at(config); + changes.retain(|change| !change.path.starts_with(config.join(Self::PLUGIN_DIRS[0]))); + changes.push(Change::write( + config.join(Self::PLUGIN_DIRS[0]).join(Self::PLUGIN_FILE), + self.plugin(program)?, + )); + Ok(changes) + } + + fn disconnect_at(&self, config: &Path) -> Vec { + Self::PLUGIN_DIRS + .iter() + .map(|dir| config.join(dir).join(Self::PLUGIN_FILE)) + .filter(|path| Self::is_ours(path)) + .map(Change::remove) + .collect() + } + + fn connected_at(config: &Path) -> bool { + Self::PLUGIN_DIRS + .iter() + .any(|dir| Self::is_ours(&config.join(dir).join(Self::PLUGIN_FILE))) + } + + fn is_ours(path: &Path) -> bool { + fs::read_to_string(path).is_ok_and(|text| text.contains(Self::MARKER)) + } + + fn string<'v>(args: &'v Value, key: &str) -> Option<&'v str> { + args.get(key) + .and_then(Value::as_str) + .filter(|text| !text.is_empty()) + } + + fn observe_tool(input: &OpenCodeHookInput, observer: &Observer<'_>) -> Vec { + let Some(tool) = input.tool.as_deref() else { + return Vec::new(); + }; + if Self::BOOKKEEPING_TOOLS.contains(&tool) { + return Vec::new(); + } + let args = &input.args; + let path = Self::string(args, "filePath"); + let observation = match tool { + "bash" => return Vec::new(), + "edit" | "multiedit" => { + let Some(path) = path else { return Vec::new() }; + let edit = match (input.diff.as_deref(), Self::string(args, "oldString")) { + (Some(diff), _) => Edit::Patched { diff }, + (None, Some(old)) => Edit::Replaced { + old, + new: Self::string(args, "newString").unwrap_or_default(), + }, + (None, None) => Edit::Unknown, + }; + observer.edit(tool, path, edit, true) + } + "write" => { + let Some(path) = path else { return Vec::new() }; + let edit = match (input.exists, Self::string(args, "content")) { + (Some(true), _) | (_, None) => Edit::Unknown, + (_, Some(content)) => Edit::Created { content }, + }; + observer.edit(tool, path, edit, true) + } + "apply_patch" => { + return Patch::files(Self::string(args, "patchText").unwrap_or_default()) + .into_iter() + .map(|(path, created, body)| { + let edit = if created { + Edit::Created { content: &body } + } else { + Edit::Patched { diff: &body } + }; + observer.edit(tool, &path, edit, true) + }) + .collect(); + } + "read" => match path { + Some(path) => observer.read(tool, path), + None => return Vec::new(), + }, + "grep" | "glob" | "list" | "ls" => observer.search( + tool, + Self::string(args, "pattern"), + Self::string(args, "path"), + ), + "webfetch" => observer.fetch(tool, Self::string(args, "url"), None), + "websearch" | "codesearch" => observer.fetch(tool, None, Self::string(args, "query")), + "task" => observer.other(tool, ToolAction::Delegate), + _ => observer.other(tool, ToolAction::Other), + }; + vec![observation] + } +} + +#[derive(Debug)] +struct Patch; + +impl Patch { + fn files(text: &str) -> Vec<(String, bool, String)> { + let mut files: Vec<(String, bool, String)> = Vec::new(); + for line in text.lines() { + let header = [ + ("*** Add File:", true), + ("*** Update File:", false), + ("*** Delete File:", false), + ] + .into_iter() + .find_map(|(prefix, created)| { + line.strip_prefix(prefix).map(|path| (path.trim(), created)) + }); + if let Some((path, created)) = header { + files.push((path.to_owned(), created, String::new())); + } else if line.starts_with("*** ") { + continue; + } else if let Some((_, created, body)) = files.last_mut() { + let line = if *created { + line.strip_prefix('+').unwrap_or(line) + } else { + line + }; + body.push_str(line); + body.push('\n'); + } + } + files + } +} + +impl Agent for OpenCode { + fn title(&self) -> &'static str { + self.title + } + + fn journaled(&self) -> bool { + true + } + + fn history(&self) -> Result> { + Ok(None) + } + + fn transcripts(&self, _history: &Path) -> Result> { + Ok(Vec::new()) + } + + fn parse(&self, text: &str, redactor: &Redactor) -> Result<(Trace, Option)> { + Ok(( + Journal::parse(text, self.harness, redactor)?, + Journal::working_directory(text).map(PathBuf::from), + )) + } + + fn event(&self, payload: &str, redactor: &Redactor) -> Result> { + let input: OpenCodeHookInput = + serde_json::from_str(payload).context("parse the hook payload")?; + if input.session.is_empty() || !input.cwd.is_absolute() { + return Ok(None); + } + let root = input.cwd.to_string_lossy().into_owned(); + let here = Self::string(&input.args, "workdir") + .filter(|dir| Path::new(dir).is_absolute()) + .map_or_else(|| root.clone(), str::to_owned); + let observer = Observer { + session: &input.session, + cwd: &root, + at: Timestamp::now(), + model: input.model.as_deref(), + redactor, + }; + let mut observed = Vec::new(); + let moment = match input.event.as_str() { + "session_start" => Moment::SessionStart, + "prompt" => { + let Some(prompt) = input + .prompt + .as_deref() + .filter(|prompt| !prompt.trim().is_empty()) + else { + return Ok(None); + }; + observed.push(observer.prompt(prompt)); + Moment::Prompt(prompt.to_owned()) + } + "tool" if input.tool.as_deref() == Some("bash") => { + let output = input.output.as_deref().unwrap_or_default(); + let failed = input.exit.is_some_and(|code| code != 0); + let shell = Observer { + cwd: &here, + ..observer + }; + observed.push(shell.command( + "bash", + Self::string(&input.args, "command").unwrap_or_default(), + Ran { + output, + exit_code: input.exit.and_then(|code| i32::try_from(code).ok()), + failed, + interrupted: input.exit.is_none(), + duration_ms: None, + }, + )); + if failed { + Moment::CommandFailed(output.to_owned()) + } else { + Moment::ToolDone + } + } + "tool" => { + observed.extend(Self::observe_tool(&input, &observer)); + Moment::ToolDone + } + "turn_end" => Moment::TurnEnd { continued: false }, + "compacting" => { + observed.push(observer.compaction(true)); + Moment::Compacting + } + _ => return Ok(None), + }; + Ok(Some(HookEvent { + name: input.event, + session: input.session, + cwd: input.cwd, + transcript: None, + moment, + observed, + })) + } + + fn render(&self, event: &HookEvent, reply: Reply<'_>) -> Option { + match (&event.moment, reply) { + (Moment::Prompt(_) | Moment::CommandFailed(_), Reply::Recall(envelope)) => { + Some(envelope.to_owned()) + } + _ => None, + } + } + + fn detected(&self) -> bool { + self.config().is_ok_and(|config| config.is_dir()) + || self.data().is_ok_and(|data| data.is_dir()) + } + + fn notice(&self) -> Option<&'static str> { + Some(self.notice) + } + + fn connect(&self, program: &Program) -> Result> { + self.connect_at(&self.config()?, program) + } + + fn disconnect(&self) -> Result> { + Ok(self.disconnect_at(&self.config()?)) + } + + fn connected(&self) -> Result { + Ok(Self::connected_at(&self.config()?)) + } +} + +#[cfg(test)] +mod tests { + use std::process; + + use serde_json::json; + use trodden_core::trace::{EventKind, ToolOutcome}; + + use super::*; + + const ROOT: &str = "/home/dev/shop"; + + #[derive(Debug)] + struct Payload; + + impl Payload { + fn event(agent: &OpenCode, payload: Value) -> Option { + agent + .event(&payload.to_string(), &Redactor::with_home("/home/dev")) + .expect("payload parses") + } + + fn tool(tool: &str, args: Value, extra: Value) -> Value { + let mut payload = json!({"event": "tool", "session": "ses_6655", "cwd": ROOT, "tool": tool, "args": args}); + payload + .as_object_mut() + .expect("object") + .extend(extra.as_object().expect("object").clone()); + payload + } + + fn rendered(moment: Moment, reply: Reply<'_>) -> Option { + let event = HookEvent { + name: String::new(), + session: "ses_6655".to_owned(), + cwd: PathBuf::from(ROOT), + transcript: None, + moment, + observed: Vec::new(), + }; + OpenCode::OPENCODE.render(&event, reply) + } + } + + #[derive(Debug)] + struct Scratch { + dir: PathBuf, + } + + impl Scratch { + fn new(name: &str) -> Self { + let dir = env::temp_dir().join(format!("trodden-opencode-{name}-{}", process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("scratch dir is writable"); + Self { dir } + } + + fn apply(changes: Vec) { + for change in changes { + change.apply().expect("change applies"); + } + } + } + + impl Drop for Scratch { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.dir); + } + } + + #[test] + fn prompts_and_failures_are_recalled_as_plain_text() { + let prompt = Payload::event( + &OpenCode::OPENCODE, + json!({"event": "prompt", "session": "ses_6655", "cwd": ROOT, + "prompt": "fix the failing cargo test", "model": "claude-sonnet-4-5"}), + ) + .expect("event"); + let failed = Payload::event( + &OpenCode::OPENCODE, + Payload::tool( + "bash", + json!({"command": "cargo test", "workdir": ROOT}), + json!({"output": "error[E0433]: failed to resolve", "exit": 101}), + ), + ) + .expect("event"); + let passed = Payload::event( + &OpenCode::OPENCODE, + Payload::tool( + "bash", + json!({"command": "cargo test"}), + json!({"output": "ok", "exit": 0}), + ), + ) + .expect("event"); + + assert_eq!( + prompt.moment, + Moment::Prompt("fix the failing cargo test".to_owned()) + ); + assert_eq!( + prompt.observed[0].model.as_deref(), + Some("claude-sonnet-4-5") + ); + assert_eq!( + failed.moment, + Moment::CommandFailed("error[E0433]: failed to resolve".to_owned()) + ); + assert_eq!(passed.moment, Moment::ToolDone); + assert_eq!( + Payload::rendered(Moment::Prompt("p".to_owned()), Reply::Recall("")), + Some("".to_owned()) + ); + assert_eq!( + Payload::rendered(Moment::CommandFailed("e".to_owned()), Reply::Recall("")), + Some("".to_owned()) + ); + assert_eq!( + Payload::rendered( + Moment::TurnEnd { continued: false }, + Reply::Remind("run it") + ), + None + ); + } + + #[test] + fn lifecycle_events_map_to_moments() { + let moment = |event: &str| { + Payload::event( + &OpenCode::KILO, + json!({"event": event, "session": "ses_6655", "cwd": ROOT}), + ) + .map(|event| event.moment) + }; + + assert_eq!(moment("session_start"), Some(Moment::SessionStart)); + assert_eq!( + moment("turn_end"), + Some(Moment::TurnEnd { continued: false }) + ); + assert_eq!(moment("compacting"), Some(Moment::Compacting)); + assert_eq!(moment("prompt"), None); + assert_eq!(moment("dispose"), None); + assert_eq!( + Payload::event( + &OpenCode::KILO, + json!({"event": "turn_end", "session": "s", "cwd": "shop"}) + ), + None + ); + assert!( + OpenCode::KILO + .event("{}", &Redactor::with_home("/home/dev")) + .is_err() + ); + } + + #[test] + fn a_session_journals_into_a_trace() { + let payloads = [ + json!({"event": "prompt", "session": "ses_6655", "cwd": ROOT, "prompt": "Page 2 repeats the last product. Fix it."}), + Payload::tool( + "read", + json!({"filePath": format!("{ROOT}/src/paginate.js")}), + json!({"output": "..."}), + ), + Payload::tool( + "edit", + json!({"filePath": format!("{ROOT}/src/paginate.js"), "oldString": "end + 1", "newString": "end"}), + json!({"diff": "Index: src/paginate.js\n--- a\n+++ b\n@@ -3,1 +3,1 @@\n- return items.slice(start, end + 1);\n+ return items.slice(start, end);\n"}), + ), + Payload::tool( + "write", + json!({"filePath": format!("{ROOT}/CHANGELOG.md"), "content": "# Changes\n- paging\n"}), + json!({"exists": false}), + ), + Payload::tool( + "apply_patch", + json!({"patchText": "*** Begin Patch\n*** Add File: src/tax.js\n+export const RATE = 0.2;\n+export default RATE;\n*** Update File: src/cart.js\n@@\n- return sum;\n+ return sum + tax;\n*** End Patch"}), + json!({}), + ), + Payload::tool("todowrite", json!({}), json!({})), + Payload::tool( + "bash", + json!({"command": "npm test", "workdir": format!("{ROOT}/web")}), + json!({"output": "1 passing", "exit": 0}), + ), + Payload::tool( + "bash", + json!({"command": "npm run e2e"}), + json!({"output": "User aborted the command", "exit": null}), + ), + json!({"event": "compacting", "session": "ses_6655", "cwd": ROOT}), + ]; + let mut journal = String::new(); + for payload in payloads { + for observation in Payload::event(&OpenCode::OPENCODE, payload) + .expect("event") + .observed + { + journal.push_str(&observation.to_line().expect("line encodes")); + } + } + + let (trace, cwd) = OpenCode::OPENCODE + .parse(&journal, &Redactor::with_home("/home/dev")) + .expect("journal parses"); + let calls: Vec<_> = trace + .events + .iter() + .filter_map(|event| match &event.kind { + EventKind::ToolCall(call) => Some(call), + _ => None, + }) + .collect(); + + assert_eq!(cwd, Some(PathBuf::from(ROOT))); + assert_eq!(trace.harness.as_str(), "opencode"); + assert_eq!(trace.session.as_str(), "ses_6655"); + assert!( + matches!(&trace.events[0].kind, EventKind::Prompt { summary } if summary == "Page 2 repeats the last product. Fix it.") + ); + assert_eq!(calls.len(), 7, "{calls:?}"); + assert_eq!(calls[0].action, ToolAction::Read); + assert_eq!(calls[1].changes[0].path, "src/paginate.js"); + assert_eq!( + ( + calls[1].changes[0].lines_added, + calls[1].changes[0].lines_removed + ), + (1, 1) + ); + assert!(calls[2].changes[0].created); + assert_eq!(calls[2].changes[0].lines_added, 2); + assert_eq!(calls[3].changes[0].path, "src/tax.js"); + assert!(calls[3].changes[0].created); + assert_eq!(calls[3].changes[0].lines_added, 2); + assert_eq!( + ( + calls[4].changes[0].lines_added, + calls[4].changes[0].lines_removed + ), + (1, 1) + ); + assert_eq!(calls[5].args.command.as_deref(), Some("cd web && npm test")); + assert_eq!(calls[5].outcome, ToolOutcome::Succeeded); + assert_eq!(calls[6].outcome, ToolOutcome::Interrupted); + assert!(matches!( + trace.events.last().map(|event| &event.kind), + Some(EventKind::Compaction { .. }) + )); + } + + #[test] + fn the_plugin_names_its_agent_and_program() { + let program = Program::at("/Users/dev/Application Support/it's/trodden"); + let opencode = OpenCode::OPENCODE.plugin(&program).expect("renders"); + let kilo = OpenCode::KILO.plugin(&program).expect("renders"); + + assert!( + opencode.contains(r#"const PROGRAM = "/Users/dev/Application Support/it's/trodden""#), + "{opencode}" + ); + assert!(opencode.contains(r#"const AGENT = "opencode""#)); + assert!(opencode.starts_with("// Written by `trodden connect opencode`")); + assert!(kilo.contains(r#"const AGENT = "kilo""#)); + assert!(!kilo.contains("__"), "every placeholder is filled"); + } + + #[test] + fn connect_owns_only_its_plugin_file() { + let scratch = Scratch::new("connect"); + let program = Program::at("/home/dev/.cargo/bin/trodden"); + let theirs = scratch.dir.join("plugin").join("trodden.js"); + fs::create_dir_all(theirs.parent().expect("parent")).expect("writable"); + fs::write(&theirs, "export const Mine = async () => ({})\n").expect("writable"); + let legacy = scratch.dir.join("plugin").join("other.js"); + fs::write(&legacy, "export const Other = async () => ({})\n").expect("writable"); + + assert!(!OpenCode::connected_at(&scratch.dir)); + Scratch::apply( + OpenCode::KILO + .connect_at(&scratch.dir, &program) + .expect("connects"), + ); + let ours = scratch.dir.join("plugins").join("trodden.js"); + assert!(OpenCode::connected_at(&scratch.dir)); + assert!( + OpenCode::KILO + .connect_at(&scratch.dir, &program) + .expect("connects") + .iter() + .all(|change| !change.is_needed()), + "idempotent" + ); + assert!( + fs::read_to_string(&ours) + .expect("written") + .contains(r#"const AGENT = "kilo""#) + ); + + Scratch::apply(OpenCode::KILO.disconnect_at(&scratch.dir)); + assert!(!ours.exists()); + assert!( + theirs.exists() && legacy.exists(), + "files Trodden did not write stay" + ); + assert!(!OpenCode::connected_at(&scratch.dir)); + } +} diff --git a/crates/trodden/src/harness/qwen.rs b/crates/trodden/src/harness/qwen.rs new file mode 100644 index 0000000..1bb57f0 --- /dev/null +++ b/crates/trodden/src/harness/qwen.rs @@ -0,0 +1,492 @@ +use std::{ + env, fs, + path::{Path, PathBuf}, +}; + +use anyhow::{Context, Result}; +use serde::Deserialize; +use serde_json::json; +use trodden_capture::{Reminder, qwen::Transcript}; +use trodden_core::Trace; +use trodden_redact::Redactor; + +use super::{Agent, Change, HookEvent, Moment, Reply, claude_code::ClaudeHookInput}; +use crate::connect::{HookFile, Program}; + +#[derive(Debug)] +pub(crate) struct Qwen; + +#[derive(Debug, Default, Deserialize)] +struct QwenHookExtras { + #[serde(default)] + submitted_prompt: Option, +} + +impl Qwen { + const SHELL: &[&str] = &["run_shell_command"]; + + fn shell_output(error: &str) -> String { + let Some(start) = error + .strip_prefix("Output: ") + .or_else(|| error.split_once("\nOutput: ").map(|(_, rest)| rest)) + else { + return error.to_owned(); + }; + let end = start + .rfind("\nError: ") + .filter(|at| start[*at..].contains("\nExit Code: ")) + .or_else(|| start.rfind("\nExit Code: ")) + .unwrap_or(start.len()); + let output = start[..end].trim(); + if output.is_empty() || output == "(empty)" { + error.to_owned() + } else { + output.to_owned() + } + } +} + +impl Agent for Qwen { + fn title(&self) -> &'static str { + "Qwen Code" + } + + fn history(&self) -> Result> { + Ok(Some(QwenHome::locate()?.history())) + } + + fn transcripts(&self, projects: &Path) -> Result> { + QwenHome::transcripts(projects) + } + + fn parse(&self, text: &str, redactor: &Redactor) -> Result<(Trace, Option)> { + Ok(( + Transcript::parse(text, redactor)?, + Transcript::working_directory(text).map(PathBuf::from), + )) + } + + fn event(&self, payload: &str, _redactor: &Redactor) -> Result> { + let Some(input) = ClaudeHookInput::parse(payload)? else { + return Ok(None); + }; + let extras: QwenHookExtras = + serde_json::from_str(payload).context("parse the Qwen Code hook payload")?; + let moment = match input.hook_event_name.as_str() { + "SessionStart" => Moment::SessionStart, + "UserPromptSubmit" => match extras.submitted_prompt.as_deref().map(str::trim) { + Some(prompt) if !prompt.is_empty() && !prompt.starts_with(Reminder::PREFIX) => { + Moment::Prompt(prompt.to_owned()) + } + _ => return Ok(None), + }, + "PostToolUseFailure" => match input.failure(Self::SHELL) { + Some(error) => Moment::CommandFailed(Self::shell_output(&error)), + None => return Ok(None), + }, + "Stop" => Moment::TurnEnd { + continued: input.stop_hook_active, + }, + "PreCompact" => Moment::Compacting, + "SessionEnd" => Moment::SessionEnd, + _ => return Ok(None), + }; + Ok(input.event(moment)) + } + + fn render(&self, event: &HookEvent, reply: Reply<'_>) -> Option { + match (&event.moment, reply) { + (Moment::Prompt(_), Reply::Recall(envelope)) => Some(ClaudeHookInput::recall_context( + "UserPromptSubmit", + envelope, + )), + (Moment::CommandFailed(_), Reply::Recall(envelope)) => Some( + ClaudeHookInput::recall_context("PostToolUseFailure", envelope), + ), + (Moment::TurnEnd { .. }, Reply::Remind(reason)) => Some(ClaudeHookInput::block(reason)), + _ => None, + } + } + + fn detected(&self) -> bool { + QwenHome::locate().is_ok_and(|home| home.config.is_dir()) + } + + fn connect(&self, program: &Program) -> Result> { + QwenHome::locate()?.connect(program) + } + + fn disconnect(&self) -> Result> { + QwenHome::locate()?.disconnect() + } + + fn connected(&self) -> Result { + QwenHome::locate()?.connected() + } +} + +#[derive(Debug, Clone)] +struct QwenHome { + config: PathBuf, + runtime: PathBuf, +} + +impl QwenHome { + const EVENTS: &[(&str, Option<&str>)] = &[ + ("SessionStart", None), + ("UserPromptSubmit", None), + ("PostToolUseFailure", Some("^run_shell_command$")), + ("Stop", None), + ("PreCompact", None), + ("SessionEnd", None), + ]; + + const TIMEOUT_SECONDS: u64 = 5; + + fn locate() -> Result { + let config = match env::var_os("QWEN_HOME").filter(|dir| !dir.is_empty()) { + Some(dir) => PathBuf::from(dir), + None => env::home_dir() + .context("find the home directory")? + .join(".qwen"), + }; + let runtime = env::var_os("QWEN_RUNTIME_DIR") + .filter(|dir| !dir.is_empty()) + .map_or_else(|| config.clone(), PathBuf::from); + Ok(Self { config, runtime }) + } + + #[cfg(test)] + fn at(dir: impl Into) -> Self { + let dir = dir.into(); + Self { + config: dir.clone(), + runtime: dir, + } + } + + fn settings(&self) -> PathBuf { + self.config.join("settings.json") + } + + fn history(&self) -> PathBuf { + self.runtime.join("projects") + } + + fn transcripts(projects: &Path) -> Result> { + let mut transcripts = Vec::new(); + for project in + fs::read_dir(projects).with_context(|| format!("list {}", projects.display()))? + { + let chats = project + .context("read a project directory entry")? + .path() + .join("chats"); + let Ok(entries) = fs::read_dir(&chats) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() && path.extension().is_some_and(|ext| ext == "jsonl") { + transcripts.push(path); + } + } + } + transcripts.sort(); + Ok(transcripts) + } + + fn load(&self) -> Result { + HookFile::load(self.settings()).context("load the Qwen Code settings") + } + + fn connect(&self, program: &Program) -> Result> { + let mut file = self.load()?; + file.remove(&["hooks"], "qwen"); + let command = program.hook("qwen"); + for (event, matcher) in Self::EVENTS { + let handler = json!({ + "type": "command", + "name": "trodden", + "command": command, + "timeout": Self::TIMEOUT_SECONDS, + }); + let group = match matcher { + Some(matcher) => json!({"matcher": matcher, "hooks": [handler]}), + None => json!({"hooks": [handler]}), + }; + file.add(&["hooks"], event, group)?; + } + Ok(file.change()?.into_iter().collect()) + } + + fn disconnect(&self) -> Result> { + if !self.settings().exists() { + return Ok(Vec::new()); + } + let mut file = self.load()?; + file.remove(&["hooks"], "qwen"); + Ok(file.change()?.into_iter().collect()) + } + + fn connected(&self) -> Result { + if !self.settings().exists() { + return Ok(false); + } + Ok(self.load()?.contains(&["hooks"], "qwen")) + } +} + +#[cfg(test)] +mod tests { + use std::process; + + use serde_json::Value; + + use super::*; + + #[derive(Debug)] + struct Scratch { + dir: PathBuf, + } + + impl Scratch { + fn new(name: &str) -> Self { + let dir = env::temp_dir().join(format!("trodden-qwen-{name}-{}", process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("scratch dir is writable"); + Self { dir } + } + + fn apply(changes: Vec) { + for change in changes { + change.apply().expect("change applies"); + } + } + + fn read(path: &Path) -> Value { + serde_json::from_str(&fs::read_to_string(path).expect("read")).expect("JSON") + } + } + + impl Drop for Scratch { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.dir); + } + } + + #[derive(Debug)] + struct Payload; + + impl Payload { + fn moment(payload: Value) -> Option { + let mut base = json!({"session_id": "6f1c2a9e", "transcript_path": "/home/dev/.qwen/projects/-home-dev-shop/chats/6f1c2a9e.jsonl", + "cwd": "/home/dev/shop", "timestamp": "2026-10-09T10:12:03.511Z", "permission_mode": "default"}); + base.as_object_mut() + .expect("object") + .extend(payload.as_object().expect("object").clone()); + Qwen.event(&base.to_string(), &Redactor::with_home("/home/dev")) + .expect("payload parses") + .map(|event| event.moment) + } + } + + #[test] + fn only_typed_prompts_recall() { + assert_eq!( + Payload::moment( + json!({"hook_event_name": "UserPromptSubmit", "prompt": "fix the migration test", "submitted_prompt": "fix the migration test"}) + ), + Some(Moment::Prompt("fix the migration test".to_owned())) + ); + assert_eq!( + Payload::moment(json!({"hook_event_name": "UserPromptSubmit", "prompt": ""})), + None + ); + assert_eq!( + Payload::moment( + json!({"hook_event_name": "UserPromptSubmit", "prompt": "Trodden: the procedure recalled for this task"}) + ), + None + ); + assert_eq!( + Payload::moment( + json!({"hook_event_name": "UserPromptSubmit", "prompt": "x", "submitted_prompt": " "}) + ), + None + ); + } + + #[test] + fn shell_failures_and_turn_ends_map_to_moments() { + let error = "Command: cargo test -p db\nDirectory: (root)\nOutput: error[E0432]: unresolved import\nError: (none)\nExit Code: 101"; + assert_eq!( + Payload::moment( + json!({"hook_event_name": "PostToolUseFailure", "tool_name": "run_shell_command", "tool_use_id": "t", "tool_input": {"command": "cargo test -p db"}, "error": error, "is_interrupt": false}) + ), + Some(Moment::CommandFailed( + "error[E0432]: unresolved import".to_owned() + )) + ); + assert_eq!( + Qwen::shell_output( + "Command: x\nDirectory: (root)\nOutput: (empty)\nError: (none)\nExit Code: 1" + ), + "Command: x\nDirectory: (root)\nOutput: (empty)\nError: (none)\nExit Code: 1" + ); + assert_eq!( + Qwen::shell_output("Output: a\nError: b\nExit Code: 2\nSignal: (none)"), + "a" + ); + assert_eq!(Qwen::shell_output("no block"), "no block"); + assert_eq!( + Payload::moment( + json!({"hook_event_name": "PostToolUseFailure", "tool_name": "run_shell_command", "error": "x", "is_interrupt": true}) + ), + None + ); + assert_eq!( + Payload::moment( + json!({"hook_event_name": "PostToolUseFailure", "tool_name": "edit", "error": "x"}) + ), + None + ); + assert_eq!( + Payload::moment( + json!({"hook_event_name": "Stop", "stop_hook_active": false, "last_assistant_message": "done"}) + ), + Some(Moment::TurnEnd { continued: false }) + ); + assert_eq!( + Payload::moment(json!({"hook_event_name": "PreCompact", "trigger": "auto"})), + Some(Moment::Compacting) + ); + assert_eq!( + Payload::moment( + json!({"hook_event_name": "SessionEnd", "reason": "prompt_input_exit"}) + ), + Some(Moment::SessionEnd) + ); + assert_eq!( + Payload::moment( + json!({"hook_event_name": "SessionStart", "source": "startup", "model": "qwen3"}) + ), + Some(Moment::SessionStart) + ); + assert_eq!( + Payload::moment(json!({"hook_event_name": "PostToolBatch"})), + None + ); + } + + #[test] + fn replies_are_json() { + let event = |moment| HookEvent { + name: String::new(), + session: "s".to_owned(), + cwd: PathBuf::from("/w"), + transcript: None, + moment, + observed: Vec::new(), + }; + let prompt: Value = serde_json::from_str( + &Qwen + .render( + &event(Moment::Prompt("p".to_owned())), + Reply::Recall(""), + ) + .expect("renders"), + ) + .expect("JSON"); + assert_eq!( + prompt["hookSpecificOutput"]["hookEventName"], + "UserPromptSubmit" + ); + assert_eq!(prompt["hookSpecificOutput"]["additionalContext"], ""); + let failure: Value = serde_json::from_str( + &Qwen + .render( + &event(Moment::CommandFailed("e".to_owned())), + Reply::Recall(""), + ) + .expect("renders"), + ) + .expect("JSON"); + assert_eq!( + failure["hookSpecificOutput"]["hookEventName"], + "PostToolUseFailure" + ); + assert_eq!( + Qwen.render( + &event(Moment::TurnEnd { continued: false }), + Reply::Remind("run it") + ), + Some(r#"{"decision":"block","reason":"run it"}"#.to_owned()) + ); + } + + #[test] + fn connect_round_trips_and_keeps_other_hooks() { + let scratch = Scratch::new("connect"); + let home = QwenHome::at(scratch.dir.join(".qwen")); + let program = Program::at("/opt/bin/trodden"); + + assert!(!home.connected().expect("no settings yet")); + assert!(home.disconnect().expect("nothing to remove").is_empty()); + Scratch::apply(home.connect(&program).expect("connect")); + let mut settings = Scratch::read(&home.settings()); + assert_eq!( + settings["hooks"]["PostToolUseFailure"][0]["matcher"], + "^run_shell_command$" + ); + assert_eq!(settings["hooks"]["Stop"][0]["hooks"][0]["timeout"], 5); + assert_eq!( + settings["hooks"]["Stop"][0]["hooks"][0]["command"], + "/opt/bin/trodden hook qwen" + ); + + settings["model"] = json!({"name": "qwen3-coder-plus"}); + settings["hooks"]["Stop"] + .as_array_mut() + .expect("list") + .insert( + 0, + json!({"hooks": [{"type": "command", "command": "afplay done.aiff"}]}), + ); + fs::write(home.settings(), settings.to_string()).expect("written"); + assert!(home.connect(&program).expect("connect again").is_empty()); + + Scratch::apply(home.disconnect().expect("disconnect")); + assert!(!home.connected().expect("settings load")); + assert_eq!( + Scratch::read(&home.settings()), + json!({"model": {"name": "qwen3-coder-plus"}, "hooks": {"Stop": [{"hooks": [{"type": "command", "command": "afplay done.aiff"}]}]}}) + ); + } + + #[test] + fn transcripts_are_main_session_files() { + let scratch = Scratch::new("transcripts"); + let projects = scratch.dir.join("projects"); + for path in [ + "-home-dev-shop/chats/6f1c2a9e.jsonl", + "-home-dev-shop/subagents/6f1c2a9e/agent-1.jsonl", + "-home-dev-blog/chats/a1.jsonl", + "-home-dev-blog/chats/a1.jsonl.stream", + ] { + let path = projects.join(path); + fs::create_dir_all(path.parent().expect("parent")).expect("dir"); + fs::write(&path, "{}\n").expect("written"); + } + + let found = QwenHome::transcripts(&projects).expect("list"); + + assert_eq!( + found, + [ + projects.join("-home-dev-blog/chats/a1.jsonl"), + projects.join("-home-dev-shop/chats/6f1c2a9e.jsonl") + ] + ); + } +} diff --git a/crates/trodden/src/home.rs b/crates/trodden/src/home.rs index 663691e..d9c7335 100644 --- a/crates/trodden/src/home.rs +++ b/crates/trodden/src/home.rs @@ -86,6 +86,30 @@ impl Home { self.dir.join("ingest.pending") } + pub fn journals(&self) -> PathBuf { + self.dir.join("journals") + } + + pub fn journal(&self, harness: &str, session: &str) -> PathBuf { + let plain = !session.is_empty() + && session.len() <= 128 + && !session.starts_with('.') + && session + .chars() + .all(|c| c.is_ascii_alphanumeric() || "._-".contains(c)); + let name = if plain { + session.to_owned() + } else { + let hash = session + .bytes() + .fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| { + (hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3) + }); + format!("session-{hash:016x}") + }; + self.journals().join(harness).join(format!("{name}.jsonl")) + } + pub fn error_log(&self) -> PathBuf { self.dir.join("hook.log") } @@ -337,4 +361,30 @@ mod tests { assert_eq!(scratch.logged(), ["start a background ingest"]); } + + #[test] + fn journals_are_named_after_plain_session_ids_only() { + let home = Home::at("/data/trodden"); + + assert_eq!( + home.journal("cursor", "4f1c2a9e-77d1-4c4b-9a0e-3b1f2c7d8e90"), + PathBuf::from( + "/data/trodden/journals/cursor/4f1c2a9e-77d1-4c4b-9a0e-3b1f2c7d8e90.jsonl" + ) + ); + for hostile in ["../../escape", "", ".hidden", "a/b"] { + let path = home.journal("cursor", hostile); + assert_eq!( + path.parent(), + Some(Path::new("/data/trodden/journals/cursor")) + ); + assert!( + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("session-")), + "{}", + path.display() + ); + } + } } diff --git a/crates/trodden/src/hook.rs b/crates/trodden/src/hook.rs index 74cd473..1093f91 100644 --- a/crates/trodden/src/hook.rs +++ b/crates/trodden/src/hook.rs @@ -2,8 +2,10 @@ use std::{ env, ffi::OsStr, fmt::Display, + fs::{self, OpenOptions}, io::{self, Read, Write}, panic::{self, PanicHookInfo}, + path::Path, process::{self, Command, ExitCode, Stdio}, thread, time::{Duration, Instant}, @@ -11,9 +13,8 @@ use std::{ use anyhow::{Context, Result, bail}; use jiff::Timestamp; -use serde_json::json; -use trodden::{Harness, Home, Workspace}; -use trodden_capture::{ErrorSignature, claude_code::HookInput}; +use trodden::{Harness, Home, HookEvent, Moment, Reply, Workspace}; +use trodden_capture::{ErrorSignature, Reminder, journal::Observation}; use trodden_core::{ Procedure, trace::{Event, EventKind, ToolAction, ToolOutcome}, @@ -48,15 +49,9 @@ impl Hook { } fn handle(harness: Option<&OsStr>, out: &mut Output) -> Result<()> { - let Some(known) = harness.and_then(OsStr::to_str).and_then(Harness::from_name) else { + let Some(harness) = harness.and_then(OsStr::to_str).and_then(Harness::from_name) else { bail!("unsupported harness {harness:?}"); }; - match known { - Harness::ClaudeCode => Self::claude_code(out), - } - } - - fn claude_code(out: &mut Output) -> Result<()> { let home = Home::locate()?; if !home.is_initialized() { return Ok(()); @@ -66,91 +61,123 @@ impl Hook { .take(MAX_INPUT_BYTES) .read_to_string(&mut raw) .context("read the hook payload")?; - let input: HookInput = serde_json::from_str(&raw).context("parse the hook payload")?; + let Some(mut event) = harness.event(&raw, &Redactor::new())? else { + return Ok(()); + }; + if harness.journaled() { + let journal = home.journal(harness.as_str(), &event.session); + Self::journal(&home, &journal, &event.observed)?; + event.transcript = Some(journal); + } + Self::respond(harness, &home, &event, out) + } - match input.hook_event_name.as_str() { - "UserPromptSubmit" => Self::recall(&home, &input, out), - "SessionStart" => { + fn respond( + harness: Harness, + home: &Home, + event: &HookEvent, + out: &mut Output, + ) -> Result<()> { + match &event.moment { + Moment::SessionStart => { let store = home.open_store(Patience::Interactive)?; - Workspace::resolve_cached(&input.cwd, &store).map(drop) + Workspace::resolve_cached(&event.cwd, &store).map(drop) } - "PostToolUseFailure" => Self::recall_error(&home, &input, out), - "Stop" => { - Self::spawn_ingest(&input, false)?; - Self::remind_to_verify(Harness::ClaudeCode, &home, &input, out) + Moment::Prompt(prompt) => Self::recall(harness, home, event, prompt, out), + Moment::CommandFailed(error) => Self::recall_error(harness, home, event, error, out), + Moment::ToolDone => Ok(()), + Moment::TurnEnd { continued } => { + Self::spawn_ingest(harness, event, false)?; + if *continued { + return Ok(()); + } + Self::remind_to_verify(harness, home, event, out) } - "PreCompact" => Self::spawn_ingest(&input, false), - "SessionEnd" => Self::spawn_ingest(&input, true), - _ => Ok(()), + Moment::Compacting => Self::spawn_ingest(harness, event, false), + Moment::SessionEnd => Self::spawn_ingest(harness, event, true), } } - fn recall(home: &Home, input: &HookInput, out: &mut Output) -> Result<()> { - let Some(prompt) = input.prompt.as_deref() else { + fn journal(home: &Home, journal: &Path, observed: &[Observation]) -> Result<()> { + if observed.is_empty() || home.open_store(Patience::Interactive)?.paused()? { return Ok(()); - }; + } + let mut lines = String::new(); + for observation in observed { + lines.push_str(&observation.to_line()?); + } + if let Some(dir) = journal.parent() { + fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?; + } + OpenOptions::new() + .create(true) + .append(true) + .open(journal) + .and_then(|mut file| file.write_all(lines.as_bytes())) + .with_context(|| format!("append to {}", journal.display())) + } + + fn recall( + harness: Harness, + home: &Home, + event: &HookEvent, + prompt: &str, + out: &mut Output, + ) -> Result<()> { let store = home.open_store(Patience::Interactive)?; if store.paused()? { return Ok(()); } - let Some(workspace) = Workspace::resolve_cached(&input.cwd, &store)? else { + let Some(workspace) = Workspace::resolve_cached(&event.cwd, &store)? else { return Ok(()); }; let outcome = home.recall(&store).recall(&Query { prompt, repo: workspace.repo.as_str(), root: &workspace.root, - session: Some(&input.session_id), + session: Some(&event.session), })?; if let Some(error) = &outcome.semantic_error { Self::log(format_args!("recalled without semantic matching: {error}")); } - Self::serve(&store, input, outcome, Cue::Prompt, out, |envelope| { - envelope.to_owned() - }) + Self::serve(harness, &store, event, outcome, Cue::Prompt, out) } - fn recall_error(home: &Home, input: &HookInput, out: &mut Output) -> Result<()> { - let signature = match (&input.tool_name, &input.error) { - (Some(tool), Some(error)) if tool == "Bash" && !input.is_interrupt => { - ErrorSignature::of(error, &Redactor::new()) - } - _ => None, - }; - let Some(signature) = signature else { + fn recall_error( + harness: Harness, + home: &Home, + event: &HookEvent, + error: &str, + out: &mut Output, + ) -> Result<()> { + let Some(signature) = ErrorSignature::of(error, &Redactor::new()) else { return Ok(()); }; let store = home.open_store(Patience::Interactive)?; if store.paused()? { return Ok(()); } - let Some(workspace) = Workspace::resolve_cached(&input.cwd, &store)? else { + let Some(workspace) = Workspace::resolve_cached(&event.cwd, &store)? else { return Ok(()); }; let outcome = Recall::new(&store, None).recall_error(&ErrorQuery { signature: &signature, repo: workspace.repo.as_str(), root: &workspace.root, - session: Some(&input.session_id), + session: Some(&event.session), })?; - Self::serve(&store, input, outcome, Cue::Failure, out, |envelope| { - json!({ - "hookSpecificOutput": { - "hookEventName": "PostToolUseFailure", - "additionalContext": envelope, - } - }) - .to_string() - }) + Self::serve(harness, &store, event, outcome, Cue::Failure, out) } + // Nothing is recorded when the agent cannot show it: an unseen holdout would skew the + // comparison. fn serve( + harness: Harness, store: &Store, - input: &HookInput, + event: &HookEvent, outcome: Outcome, cue: Cue, out: &mut Output, - format: impl Fn(&str) -> String, ) -> Result<()> { let (chosen, holdout): (Box, bool) = match outcome.decision { Decision::Inject(chosen) => (chosen, false), @@ -158,8 +185,12 @@ impl Hook { Decision::Abstain(_) => return Ok(()), }; let procedure = &chosen.row.procedure; + let envelope = Envelope::render(procedure); + let Some(rendered) = harness.render(event, Reply::Recall(&envelope)) else { + return Ok(()); + }; let injection = Injection { - session: input.session_id.clone(), + session: event.session.clone(), procedure: procedure.id.to_string(), revision: procedure.revision, holdout, @@ -171,8 +202,7 @@ impl Hook { }) .context("record the injection before showing the procedure")?; if !holdout { - writeln!(out, "{}", format(&Envelope::render(procedure))) - .context("print the recalled procedure")?; + writeln!(out, "{rendered}").context("print the recalled procedure")?; out.flush().context("flush the recalled procedure")?; } Ok(()) @@ -194,12 +224,16 @@ impl Hook { } } + // Gemini CLI and Qwen Code write the prompt after its hook ran, so it can be stamped after + // its injection. + const PROMPT_LAG: jiff::SignedDuration = jiff::SignedDuration::from_secs(10); + fn edited_since_check(events: &[Event], injected: Timestamp, check: &str) -> bool { let turn = match events .iter() .rposition(|event| matches!(event.kind, EventKind::Prompt { .. })) { - Some(start) if events[start].at > injected => return false, + Some(start) if events[start].at > injected + Self::PROMPT_LAG => return false, Some(start) => &events[start..], None => events, }; @@ -223,32 +257,31 @@ impl Hook { edited_since_check } + // At most one reminder per injection: several agents never say a turn already continued. fn remind_to_verify( harness: Harness, home: &Home, - input: &HookInput, + event: &HookEvent, out: &mut Output, ) -> Result<()> { - if input.stop_hook_active { - return Ok(()); - } - let (Some(transcript), store) = ( - &input.transcript_path, - home.open_store(Patience::Interactive)?, - ) else { + let Some(transcript) = &event.transcript else { return Ok(()); }; + let store = home.open_store(Patience::Interactive)?; if !store.verify_reminder()? || store.paused()? { return Ok(()); } let Some(injected) = store - .injections(Some(&input.session_id))? + .injections(Some(&event.session))? .into_iter() .rev() .find(|record| !record.injection.holdout) else { return Ok(()); }; + if injected.reminded { + return Ok(()); + } let Some(procedure) = store .revisions(&injected.injection.procedure)? .into_iter() @@ -270,14 +303,16 @@ impl Hook { let (trace, _) = harness .parse(&text, &Redactor::new()) .with_context(|| format!("parse {}", transcript.display()))?; - let edited_since_check = - Self::edited_since_check(&trace.events, injected.injection.at, check); - if edited_since_check { - let reason = Self::reminder(&procedure, check); - writeln!(out, "{}", json!({ "decision": "block", "reason": reason })) - .context("print the verify reminder")?; - out.flush().context("flush the verify reminder")?; + if !Self::edited_since_check(&trace.events, injected.injection.at, check) { + return Ok(()); } + let reason = Self::reminder(&procedure, check); + let Some(rendered) = harness.render(event, Reply::Remind(&reason)) else { + return Ok(()); + }; + store.mark_reminded(injected.rowid, Timestamp::now())?; + writeln!(out, "{rendered}").context("print the verify reminder")?; + out.flush().context("flush the verify reminder")?; Ok(()) } @@ -306,20 +341,23 @@ impl Hook { format!(" ({}; fill in what fits this change)", slots.join("; ")) }; format!( - "Trodden: the procedure recalled for this task is checked with `{check}`{fill}, \ - which has not run since your last change. Run it once before finishing." + "{} for this task is checked with `{check}`{fill}, \ + which has not run since your last change. Run it once before finishing.", + Reminder::PREFIX ) } - fn spawn_ingest(input: &HookInput, ended: bool) -> Result<()> { - let Some(transcript) = &input.transcript_path else { + fn spawn_ingest(harness: Harness, event: &HookEvent, ended: bool) -> Result<()> { + let Some(transcript) = &event.transcript else { return Ok(()); }; let mut command = Command::new(env::current_exe().context("find the trodden executable")?); - command - .arg("ingest") - .arg(transcript) - .args(["--quiet", "--background"]); + command.arg("ingest").arg(transcript).args([ + "--agent", + harness.as_str(), + "--quiet", + "--background", + ]); if ended { command.arg("--ended"); } @@ -347,7 +385,7 @@ impl Hook { mod tests { use std::{cell::Cell, fs, path::PathBuf}; - use serde_json::Value; + use serde_json::{Value, json}; use trodden_capture::claude_code::Transcript; use trodden_core::procedure::{Slot, SlotKind}; use trodden_recall::Signals; @@ -384,10 +422,14 @@ mod tests { } fn serve(&self, decision: fn(Box) -> Decision) -> (Result<()>, String) { - let input: HookInput = serde_json::from_value(json!({ - "session_id": "s", "cwd": CWD, "hook_event_name": "UserPromptSubmit", - })) - .expect("valid hook payload"); + let event = HookEvent { + name: "UserPromptSubmit".to_owned(), + session: "s".to_owned(), + cwd: PathBuf::from(CWD), + transcript: None, + moment: Moment::Prompt("Fix the paging bug".to_owned()), + observed: Vec::new(), + }; let chosen = Box::new(Match { row: ProcedureRow { rowid: 1, @@ -402,12 +444,12 @@ mod tests { }; let mut printed = Vec::new(); let result = Hook::serve( + Harness::ClaudeCode, &self.store, - &input, + &event, outcome, Cue::Prompt, &mut Output::new(&mut printed), - str::to_owned, ); ( result, @@ -468,16 +510,19 @@ mod tests { fn remind(&self, transcript: &[u8]) -> String { let path = self.home.dir().join("transcript.jsonl"); fs::write(&path, transcript).expect("transcript is writable"); - let input: HookInput = serde_json::from_value(json!({ - "session_id": "s", "cwd": CWD, "hook_event_name": "Stop", - "transcript_path": path, - })) - .expect("valid hook payload"); + let event = HookEvent { + name: "Stop".to_owned(), + session: "s".to_owned(), + cwd: PathBuf::from(CWD), + transcript: Some(path), + moment: Moment::TurnEnd { continued: false }, + observed: Vec::new(), + }; let mut printed = Vec::new(); Hook::remind_to_verify( Harness::ClaudeCode, &self.home, - &input, + &event, &mut Output::new(&mut printed), ) .expect("reminder runs"); @@ -674,7 +719,9 @@ mod tests { transcript.extend(b"{\"type\":\"user\",\"sessionId\":\"s\",\"note\":\"caf\xe9\"}\n"); let printed = reminding.remind(&transcript); + let again = reminding.remind(&transcript); + assert_eq!(again, "", "an injection is reminded once"); let reminder: Value = serde_json::from_str(&printed).expect("reminder is JSON"); assert_eq!(reminder["decision"], "block"); assert!( @@ -723,6 +770,18 @@ mod tests { assert!(!checked.needs_check("2026-09-21T14:05:00.120Z")); } + #[test] + fn a_prompt_recorded_after_its_injection_still_starts_its_turn() { + let session = Session::default() + .prompt( + "2026-09-21T14:00:01.500Z", + "Fix the paging bug in src/paginate.js", + ) + .edit("2026-09-21T14:00:05.000Z", "src/paginate.js"); + + assert!(session.needs_check("2026-09-21T14:00:00.120Z")); + } + #[test] fn a_failure_injection_only_counts_edits_made_after_it() { let failed = Session::default() diff --git a/crates/trodden/src/ingest.rs b/crates/trodden/src/ingest.rs index aca363c..08ed74b 100644 --- a/crates/trodden/src/ingest.rs +++ b/crates/trodden/src/ingest.rs @@ -46,7 +46,7 @@ impl IngestReport { } } - fn absorb(&mut self, other: Self) { + pub fn absorb(&mut self, other: Self) { self.sessions += other.sessions; self.tasks += other.tasks; self.created += other.created; @@ -368,6 +368,9 @@ impl Ingest { }, &stamp, )?; + if ended { + self.drop_journal(harness, &session, transcript)?; + } return Ok(IngestReport::default()); } if trace.commit.is_none() { @@ -458,9 +461,26 @@ impl Ingest { if report.created + report.refreshed + report.revised > 0 || settled.any() || aged.any() { self.rebuild_index()?; } + if ended { + self.drop_journal(harness, &session, transcript)?; + } Ok(report) } + // A resumed conversation starts a new journal numbered from zero, so its progress goes too. + fn drop_journal(&self, harness: Harness, session: &str, journal: &Path) -> Result<()> { + if !harness.journaled() { + return Ok(()); + } + match fs::remove_file(journal) { + Err(error) if error.kind() != std::io::ErrorKind::NotFound => { + return Err(error).with_context(|| format!("remove {}", journal.display())); + } + _ => {} + } + self.store.forget_session(session) + } + pub fn backfill( &mut self, harness: Harness, @@ -1680,4 +1700,45 @@ mod tests { sessions.len() ); } + + #[test] + fn a_journal_is_removed_once_its_session_has_ended() { + let scratch = Scratch::new("journal-removed"); + let home = scratch.home(); + let journal = home.journal(Harness::Cursor.as_str(), "conv-1"); + fs::create_dir_all(journal.parent().expect("journals have a directory")) + .expect("journal directory is writable"); + let redactor = trodden_redact::Redactor::new(); + let observer = trodden_capture::journal::Observer { + session: "conv-1", + cwd: &scratch.dir.to_string_lossy(), + at: Timestamp::now(), + model: None, + redactor: &redactor, + }; + fs::write( + &journal, + observer + .prompt("Fix the paging bug") + .to_line() + .expect("line encodes"), + ) + .expect("journal is writable"); + let mut ingest = Ingest::start(&home).expect("ingest starts"); + + ingest + .transcript(Harness::Cursor, &journal, false) + .expect("an open session ingests"); + assert!(journal.exists(), "an open session keeps its journal"); + ingest + .transcript(Harness::Cursor, &journal, true) + .expect("an ended session ingests"); + + assert!(!journal.exists(), "an ended session's journal is removed"); + assert_eq!( + ingest.store.progress("conv-1").expect("progress reads"), + None, + "a resumed conversation starts over" + ); + } } diff --git a/crates/trodden/src/lib.rs b/crates/trodden/src/lib.rs index 7a2fb58..566cb81 100644 --- a/crates/trodden/src/lib.rs +++ b/crates/trodden/src/lib.rs @@ -1,10 +1,12 @@ +mod connect; mod harness; mod home; mod ingest; mod learning; mod workspace; -pub use harness::Harness; +pub use connect::{Change, Program}; +pub use harness::{Harness, HookEvent, Moment, Reply}; pub use home::Home; pub use ingest::{Ingest, IngestReport}; pub use workspace::Workspace;