Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions bt-daemon/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,14 +115,14 @@ echo '{"session_id":"s1","hook_event_name":"Stop"}' | ./target/debug/bt-

The first `hook` spawns the daemon detached; it idles out after 5 minutes.

`import <codex|claude> <session-id>` has a different purpose from restart
`import <codex|claude|antigravity> <session-id>` has a different purpose from restart
recovery. It locates the native transcript in the selected agent's standard
session store, synthesizes the lifecycle triggers that can be recovered from
that transcript, and sends them through the normal translator and sink to
create a trace for the past session. Hook-only facts absent from a native
transcript are not invented.

Add `--attach` to keep following an active Codex or Claude transcript until
Add `--attach` to keep following an active Codex, Claude, or Antigravity transcript until
Ctrl-C. `run <codex|claude> [ARGS...]` launches the selected agent with
inherited stdio and injects live Braintrust hooks for that invocation, so it
does not depend on the tracing plugin being installed or enabled. Managed runs
Expand Down
2 changes: 2 additions & 0 deletions bt-daemon/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,8 @@ pub enum ImportSource {
Codex,
#[value(name = "claude", alias = "claude-code")]
Claude,
#[value(name = "antigravity", alias = "agy")]
Antigravity,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
Expand Down
7 changes: 7 additions & 0 deletions bt-daemon/src/setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,11 @@ fn setup_antigravity_at(runner: &mut impl CommandRunner, config_dir: &Path) -> a
&["plugin", "install", ANTIGRAVITY_PLUGIN_SOURCE],
antigravity_home(config_dir)?,
)?;
runner.run_in_home(
"agy",
&["plugin", "enable", ANTIGRAVITY_PLUGIN],
antigravity_home(config_dir)?,
)?;

remove_legacy_antigravity_registration(config_dir)
}
Expand Down Expand Up @@ -852,6 +857,7 @@ mod tests {
setup_antigravity_at(&mut runner, &config_dir).unwrap();

assert!(runner.called(&format!("agy plugin install {ANTIGRAVITY_PLUGIN_SOURCE}")));
assert!(runner.called(&format!("agy plugin enable {ANTIGRAVITY_PLUGIN}")));
let hooks: Value = serde_json::from_slice(&std::fs::read(&hooks_path).unwrap()).unwrap();
assert_eq!(hooks["other-plugin"]["Stop"][0]["command"], "other");
assert!(hooks.get(ANTIGRAVITY_PLUGIN).is_none());
Expand Down Expand Up @@ -903,6 +909,7 @@ mod tests {

assert!(!config_dir.join("hooks.json").exists());
assert!(runner.called(&format!("agy plugin install {ANTIGRAVITY_PLUGIN_SOURCE}")));
assert!(runner.called(&format!("agy plugin enable {ANTIGRAVITY_PLUGIN}")));
}

#[test]
Expand Down
2 changes: 1 addition & 1 deletion bt-daemon/src/trace_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ pub enum TraceCommand {
/// Gracefully stop the tracing daemon.
#[command(hide = true)]
Stop(StopArgs),
/// Import a past Codex or Claude Code session by its resume id.
/// Import a past coding-agent session by its resume id.
Import(ImportArgs),
/// Launch a coding agent with tracing enabled for this invocation.
Run(RunArgs),
Expand Down
195 changes: 195 additions & 0 deletions bt-daemon/src/transcript_import/antigravity.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
use super::{envelope, validate_session_id};
use crate::wire::Envelope;
use anyhow::bail;
use serde_json::{json, Value};
use std::collections::{HashSet, VecDeque};
use std::path::{Path, PathBuf};

const TRANSCRIPT_NAME: &str = "transcript_full.jsonl";

#[derive(Default)]
pub(super) struct Tail {
started: bool,
reported_tools: HashSet<i64>,
last_len: u64,
}

impl Tail {
pub(super) fn poll(
&mut self,
events: Vec<Envelope>,
len: u64,
finalize: bool,
) -> anyhow::Result<Vec<Envelope>> {
if events.len() < 3 {
bail!("Antigravity import did not produce session boundary events");
}
let mut out = Vec::new();
if !self.started {
out.push(events[0].clone());
self.started = true;
} else if len != self.last_len {
let mut checkpoint = events[0].clone();
checkpoint.event = "ImportCheckpoint".into();
out.push(checkpoint);
}
let end = events.len() - 2;
for event in &events[1..end] {
let step = event.payload.get("stepIdx").and_then(Value::as_i64);
if step.is_some_and(|step| self.reported_tools.insert(step)) {
out.push(event.clone());
}
}
if finalize {
out.extend_from_slice(&events[end..]);
}
self.last_len = len;
Ok(out)
}
}

pub(super) fn roots(home: &Path) -> Vec<PathBuf> {
vec![home.join(".gemini/antigravity-cli/brain")]
}

pub(super) fn transcript_session_id(path: &Path) -> Option<String> {
if path.file_name().and_then(|name| name.to_str()) != Some(TRANSCRIPT_NAME) {
return None;
}
let session_id = path
.parent()? // logs
.parent()? // .system_generated
.parent()? // conversation directory
.file_name()?
.to_str()?;
validate_session_id(session_id).ok()?;
is_transcript_path(path, session_id).then(|| session_id.to_owned())
}

pub(super) fn filename_matches(path: &Path, session_id: &str) -> bool {
validate_session_id(session_id).is_ok() && is_transcript_path(path, session_id)
}

fn is_transcript_path(path: &Path, session_id: &str) -> bool {
path.file_name().and_then(|name| name.to_str()) == Some(TRANSCRIPT_NAME)
&& path
.parent()
.and_then(Path::parent)
.and_then(Path::parent)
.and_then(Path::file_name)
.and_then(|name| name.to_str())
== Some(session_id)
}

pub(super) fn envelopes(path: &Path, records: &[Value]) -> anyhow::Result<Vec<Envelope>> {
let session_id = transcript_session_id(path)
.ok_or_else(|| anyhow::anyhow!("invalid Antigravity transcript path {}", path.display()))?;
if records.is_empty() {
bail!("Antigravity transcript {} is empty", path.display());
}
let (start_ms, end_ms) = timestamp_bounds_created(records);
let transcript_path = path.to_string_lossy();
let mut events = vec![envelope(
"antigravity",
None,
&session_id,
"PreInvocation",
start_ms,
json!({"conversationId": session_id, "invocationNum": 0, "initialNumSteps": 0,
"transcriptPath": transcript_path}),
)];
let mut planned_calls = VecDeque::new();
for record in records {
if let Some(calls) = record
.get("tool_calls")
.or_else(|| record.get("toolCalls"))
.and_then(Value::as_array)
{
planned_calls.extend(calls.iter().cloned());
}
if !is_tool_result(record) {
continue;
}
let Some(step) = record
.get("step_index")
.or_else(|| record.get("stepIndex"))
.and_then(Value::as_i64)
else {
continue;
};
let mut payload = json!({
"conversationId": session_id,
"stepIdx": step,
"transcriptPath": transcript_path,
});
if let Some(call) = planned_calls.pop_front() {
payload["toolCall"] = call;
}
if is_denied(record) {
payload["toolApproval"] = json!("denied");
}
events.push(envelope(
"antigravity",
None,
&session_id,
"PostToolUse",
created_at_ms(record).unwrap_or(end_ms),
payload,
));
}
events.push(envelope(
"antigravity",
None,
&session_id,
"PostInvocation",
end_ms,
json!({"conversationId": session_id, "invocationNum": 0, "initialNumSteps": 0,
"transcriptPath": transcript_path}),
));
events.push(envelope(
"antigravity", None, &session_id, "Stop", end_ms.saturating_add(1),
json!({"conversationId": session_id, "fullyIdle": true, "terminationReason": "transcript_import",
"transcriptPath": transcript_path}),
));
Ok(events)
}

fn is_denied(record: &Value) -> bool {
record
.get("content")
.and_then(Value::as_str)
.is_some_and(|content| content.to_ascii_lowercase().contains("denied"))
}

fn is_tool_result(record: &Value) -> bool {
!matches!(
record.get("type").and_then(Value::as_str),
Some("USER_INPUT" | "PLANNER_RESPONSE" | "CONVERSATION_HISTORY" | "CHECKPOINT")
) && record
.get("step_index")
.or_else(|| record.get("stepIndex"))
.is_some()
}

fn timestamp_bounds_created(records: &[Value]) -> (i64, i64) {
let timestamps = records.iter().filter_map(created_at_ms).collect::<Vec<_>>();
timestamps
.iter()
.copied()
.fold((0, 0), |(min, max), value| {
if min == 0 {
(value, value)
} else {
(min.min(value), max.max(value))
}
})
}

fn created_at_ms(record: &Value) -> Option<i64> {
record
.get("created_at")
.or_else(|| record.get("createdAt"))
.and_then(Value::as_str)
.and_then(|value| chrono::DateTime::parse_from_rfc3339(value).ok())
.map(|value| value.timestamp_millis())
}
Loading
Loading