Skip to content
Open
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
# Build output
dist/

# Local credentials and environment overrides
.env
25 changes: 25 additions & 0 deletions bt-daemon/assets/antigravity/bin/antigravity-hook.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/bin/sh
# Thin, credential-free Antigravity hook adapter. Antigravity runs hooks from
# the directory containing hooks.json and requires a JSON response on stdout.
# Tracing is deliberately fail-open: a missing or unhealthy bt CLI must never
# interrupt the coding-agent loop.

event=${1:-}
bt_bin=${BT_BIN:-bt}

if [ -n "$event" ] && command -v "$bt_bin" >/dev/null 2>&1; then
"$bt_bin" trace hook \
--source antigravity \
--session-id-field conversationId \
--event "$event" \
--transcript-path-field transcriptPath \
--flush-on-turn-end \
>/dev/null 2>&1 || :
fi

case "$event" in
Stop) printf '{"decision":""}\n' ;;
*) printf '{}\n' ;;
esac

exit 0
37 changes: 37 additions & 0 deletions bt-daemon/assets/antigravity/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"braintrust-antigravity-tracing": {
"PostToolUse": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "sh \"./bin/antigravity-hook.sh\" PostToolUse",
"timeout": 30
}
]
}
],
"PreInvocation": [
{
"type": "command",
"command": "sh \"./bin/antigravity-hook.sh\" PreInvocation",
"timeout": 30
}
],
"PostInvocation": [
{
"type": "command",
"command": "sh \"./bin/antigravity-hook.sh\" PostInvocation",
"timeout": 30
}
],
"Stop": [
{
"type": "command",
"command": "sh \"./bin/antigravity-hook.sh\" Stop",
"timeout": 30
}
]
}
}
3 changes: 3 additions & 0 deletions bt-daemon/assets/antigravity/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"name": "braintrust-antigravity-tracing"
}
40 changes: 39 additions & 1 deletion bt-daemon/src/command_output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ pub struct SetupCommandOutput {
pub source: String,
pub display_name: String,
pub settings_path: PathBuf,
pub enabled: bool,
pub restart_required: bool,
}

Expand Down Expand Up @@ -89,6 +90,21 @@ impl TraceCommandOutput {
source: source.into(),
display_name: display_name.into(),
settings_path: settings_path.into(),
enabled: true,
restart_required: true,
})
}

pub fn setup_disabled(
source: impl Into<String>,
display_name: impl Into<String>,
settings_path: impl Into<PathBuf>,
) -> Self {
Self::Setup(SetupCommandOutput {
source: source.into(),
display_name: display_name.into(),
settings_path: settings_path.into(),
enabled: false,
restart_required: true,
})
}
Expand All @@ -112,11 +128,15 @@ impl TraceCommandOutput {
uptime_ms: status.uptime_ms.unwrap_or_default(),
sessions: status.sessions.clone(),
})?),
Self::Setup(setup) => Ok(format!(
Self::Setup(setup) if setup.enabled => Ok(format!(
"The Braintrust tracing plugin is installed for {} and configured in {}.\nRestart the coding agent to load the tracing plugin.",
setup.display_name,
setup.settings_path.display()
)),
Self::Setup(setup) => Ok(format!(
"The Braintrust tracing plugin is disabled for {}.\nRestart the coding agent to unload the tracing plugin.",
setup.display_name
)),
Self::Stop(stop) if stop.stopped => Ok("Tracing daemon stopped.".into()),
Self::Stop(_) => Ok("No tracing daemon is running.".into()),
}
Expand Down Expand Up @@ -150,10 +170,28 @@ mod tests {
let value: serde_json::Value = serde_json::from_str(&rendered).unwrap();
assert_eq!(value["command"], "setup");
assert_eq!(value["source"], "opencode");
assert_eq!(value["enabled"], true);
assert_eq!(value["restart_required"], true);
assert!(!rendered.contains("installed for"));
}

#[test]
fn disabled_setup_output_is_explicit() {
let output = TraceCommandOutput::setup_disabled(
"antigravity",
"Google Antigravity",
PathBuf::from("/tmp/antigravity/braintrust.json"),
);
let value: serde_json::Value =
serde_json::from_str(&output.render(OutputFormat::Json).unwrap()).unwrap();
assert_eq!(value["command"], "setup");
assert_eq!(value["enabled"], false);
assert!(output
.render(OutputFormat::Human)
.unwrap()
.contains("disabled for Google Antigravity"));
}

#[test]
fn stop_json_reports_idempotent_and_successful_shutdowns() {
let absent: serde_json::Value = serde_json::from_str(
Expand Down
72 changes: 71 additions & 1 deletion bt-daemon/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,11 @@ pub struct HookArgs {
/// Explicit event name (overrides `--event-field` lookup).
#[arg(long)]
pub event: Option<String>,
/// JSON field holding a transcript path. When present, capture the file
/// length observed by this hook so deterministic journal replay cannot
/// read transcript records written by later lifecycle events.
#[arg(long)]
pub transcript_path_field: Option<String>,
/// Fail instead of spawning a daemon if none is running.
#[arg(long)]
pub no_spawn: bool,
Expand Down Expand Up @@ -222,7 +227,11 @@ pub async fn run_hook(
if !settings.tracing_enabled() {
return Ok(());
}
let payload = read_stdin_json()?;
let mut payload = read_stdin_json()?;

if let Some(field) = &args.transcript_path_field {
add_transcript_observation(&mut payload, field);
}

let session_id = json_str_field(&payload, &args.session_id_field)
.ok_or_else(|| anyhow::anyhow!("no `{}` field in hook payload", args.session_id_field))?;
Expand Down Expand Up @@ -905,6 +914,47 @@ fn json_str_field(payload: &serde_json::Value, field: &str) -> Option<String> {
}
}

/// Stamp the transcript boundary visible when a blocking hook runs. Agent
/// transcripts are append-only, while daemon journal replay may happen after
/// the session has advanced. Recording byte lengths keeps translation causally
/// aligned with each native hook without copying transcript contents into the
/// journal.
fn add_transcript_observation(payload: &mut serde_json::Value, field: &str) {
let Some(path) = json_str_field(payload, field) else {
return;
};
let transcript = std::path::Path::new(&path);
let mut observation = serde_json::Map::new();
observation.insert("path".into(), serde_json::Value::String(path.clone()));
if let Ok(metadata) = std::fs::metadata(transcript) {
observation.insert(
"observed_bytes".into(),
serde_json::Value::Number(metadata.len().into()),
);
}

if transcript.file_name().and_then(|name| name.to_str()) == Some("transcript.jsonl") {
let full = transcript.with_file_name("transcript_full.jsonl");
if let Ok(metadata) = std::fs::metadata(&full) {
observation.insert(
"full_path".into(),
serde_json::Value::String(full.to_string_lossy().into_owned()),
);
observation.insert(
"full_observed_bytes".into(),
serde_json::Value::Number(metadata.len().into()),
);
}
}

if let Some(object) = payload.as_object_mut() {
object.insert(
"_bt_transcript_observation".into(),
serde_json::Value::Object(observation),
);
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -981,6 +1031,26 @@ mod tests {
assert!(now_ms() > 0);
}

#[test]
fn transcript_observation_captures_compact_and_full_boundaries() {
let dir = tempfile::tempdir().unwrap();
let compact = dir.path().join("transcript.jsonl");
let full = dir.path().join("transcript_full.jsonl");
std::fs::write(&compact, b"compact\n").unwrap();
std::fs::write(&full, b"complete record\n").unwrap();
let mut payload = serde_json::json!({
"transcriptPath": compact.to_string_lossy()
});

add_transcript_observation(&mut payload, "transcriptPath");

let observed = &payload["_bt_transcript_observation"];
assert_eq!(observed["path"], compact.to_string_lossy().as_ref());
assert_eq!(observed["observed_bytes"], 8);
assert_eq!(observed["full_path"], full.to_string_lossy().as_ref());
assert_eq!(observed["full_observed_bytes"], 16);
}

#[test]
fn import_destination_without_session_config_fails_fast() {
let mut config = None;
Expand Down
17 changes: 17 additions & 0 deletions bt-daemon/src/paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ pub const SOCKET_ENV: &str = "BT_DAEMON_SOCKET";
pub const DATA_DIR_ENV: &str = "BT_DAEMON_DATA_DIR";
/// Env override for the current agent's non-credential tracing settings file.
pub const SETTINGS_ENV: &str = "BT_DAEMON_CONFIG";
/// Env override for Antigravity's native configuration directory. Primarily
/// useful for isolated validation and managed environments.
pub const ANTIGRAVITY_CONFIG_DIR_ENV: &str = "BT_ANTIGRAVITY_CONFIG_DIR";

fn home() -> PathBuf {
std::env::var_os("HOME")
Expand Down Expand Up @@ -99,10 +102,24 @@ pub fn agent_settings_path(source: &str, explicit: Option<&Path>) -> PathBuf {
.join("opencode")
.join("braintrust.json"),
"pi" => home().join(".pi").join("agent").join("braintrust.json"),
"antigravity" => home()
.join(".gemini")
.join("config")
.join("braintrust.json"),
other => data_dir(None).join("agents").join(format!("{other}.json")),
}
}

/// Resolve Antigravity's native configuration directory.
pub(crate) fn antigravity_config_dir() -> PathBuf {
if let Some(path) = std::env::var_os(ANTIGRAVITY_CONFIG_DIR_ENV) {
if !path.is_empty() {
return PathBuf::from(path);
}
}
home().join(".gemini").join("config")
}

/// Create `dir` (and parents) mode 0700 on unix.
pub fn ensure_private_dir(dir: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dir)?;
Expand Down
Loading
Loading