Skip to content
Draft
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
37 changes: 35 additions & 2 deletions bt-daemon/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ hook processes or JavaScript plugins.
Each coding agent reads an independent non-credential `braintrust.json` file:

- Codex: `~/.codex/braintrust.json`
- Muse Code: `$XDG_CONFIG_HOME/muse/braintrust.json`, falling back to
`~/.config/muse/braintrust.json`
- Claude Code: `~/.claude/braintrust.json`
- OpenCode: `$XDG_CONFIG_HOME/opencode/braintrust.json`, falling back to
`~/.config/opencode/braintrust.json`
Expand Down Expand Up @@ -242,15 +244,46 @@ echo '{"session_id":"s1","hook_event_name":"Stop"}' | ./bt-daemon/target

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

`import <codex|claude|antigravity> <session-id>` has a different purpose from restart
`import <codex|claude|antigravity|muse> <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.

Muse imports invoke its documented local `muse export --session` interface and
read export schema version 1. `import muse --all` enumerates durable session
IDs through Muse's read-only MSP `session/list` interface, then exports each
completed session. Exports are parsed incrementally, one session at a time.
Muse Code 1.1.1 setup captures the six verified lifecycle and model hooks:
`SessionStart`, `UserPromptSubmit`, `PreLLMCall`, `PostLLMCall`, `Stop`, and
`SessionEnd`. Tool, permission, subagent, and compaction details are not
available from those hooks and are not inferred by the live translator.
`import muse <session-id> --attach` follows an active session by polling full
exports. Each snapshot is parsed with the same reader and translator as
historical import; only new envelopes are delivered, and an active snapshot
does not invent `Stop` or `SessionEnd` events. The attach exits after a native
session end, or finalizes open spans on interruption. This reads the full
export each poll, so polling cost grows with long sessions. MSP offers
cursor-paged `view/page`, but its view events omit the model request/response
records needed by this translator; `view/subscribe` also requires a session
loaded on the same host.
One-shot imports of active Muse sessions also leave spans open, allowing a
later import to add the native completion to the same deterministic trace.
`run muse` uses a process-local managed-hook override in the verified Muse Code
1.1.1 release. It retains compatible unrelated managed hooks, replaces Braintrust's saved
managed hooks only for that invocation, and leaves ordinary Muse sessions and
settings unchanged. The injected hook reads a private invocation context file
because Muse clears its hook environment. A dedicated daemon is started before
Muse so environment-based Braintrust authentication never depends on that
cleared environment. Known duplicate Braintrust user or project hooks stop the
managed run with a diagnostic. A foreign managed file using Muse's strict
handler schema is also rejected rather than silently losing hooks. Muse plugins that independently trace the same
session should be disabled before using `run muse`; new Muse versions must be
verified before the undocumented hook-path override is enabled for them.

Add `--attach` to keep following an active Codex, Claude, or Antigravity transcript until
Ctrl-C. `run <codex|claude|opencode|pi> [ARGS...]` launches the selected agent with
Ctrl-C. `run <codex|muse|claude|opencode|pi> [ARGS...]` launches the selected agent with
inherited stdio and injects Braintrust hooks or an adapter for that invocation, so it
does not depend on the tracing plugin being installed or enabled. Managed runs
suppress inherited Braintrust plugin hooks to avoid logging the same session
Expand Down
29 changes: 24 additions & 5 deletions bt-daemon/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,14 +90,23 @@ pub async fn ensure_daemon(
socket: &Path,
host: &HostInfo,
no_spawn: bool,
) -> anyhow::Result<ClientStream> {
ensure_daemon_in(socket, host, no_spawn, None).await
}

pub(crate) async fn ensure_daemon_in(
socket: &Path,
host: &HostInfo,
no_spawn: bool,
data_dir: Option<&Path>,
) -> anyhow::Result<ClientStream> {
if let Ok(s) = connect(socket).await {
return Ok(s);
}
if no_spawn {
anyhow::bail!("no daemon at {} and --no-spawn is set", socket.display());
}
spawn_daemon(host, socket)?;
spawn_daemon(host, socket, data_dir)?;
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
loop {
tokio::time::sleep(Duration::from_millis(20)).await;
Expand All @@ -112,7 +121,7 @@ pub async fn ensure_daemon(
}

#[cfg(unix)]
fn spawn_daemon(host: &HostInfo, socket: &Path) -> anyhow::Result<()> {
fn spawn_daemon(host: &HostInfo, socket: &Path, data_dir_arg: Option<&Path>) -> anyhow::Result<()> {
use std::os::unix::process::CommandExt;
use std::process::{Command, Stdio};

Expand All @@ -121,7 +130,9 @@ fn spawn_daemon(host: &HostInfo, socket: &Path) -> anyhow::Result<()> {
.split_first()
.ok_or_else(|| anyhow::anyhow!("empty serve_argv"))?;

let data_dir = crate::paths::data_dir(None);
let data_dir = data_dir_arg
.map(Path::to_path_buf)
.unwrap_or_else(|| crate::paths::data_dir(None));
let _ = crate::paths::ensure_private_dir(&data_dir);
let log = std::fs::OpenOptions::new()
.create(true)
Expand All @@ -132,6 +143,9 @@ fn spawn_daemon(host: &HostInfo, socket: &Path) -> anyhow::Result<()> {
let mut cmd = Command::new(exe);
cmd.args(rest);
cmd.arg("--socket").arg(socket);
if let Some(data_dir) = data_dir_arg {
cmd.arg("--data-dir").arg(data_dir);
}
cmd.stdin(Stdio::null());
match log {
Some(f) => {
Expand All @@ -152,7 +166,7 @@ fn spawn_daemon(host: &HostInfo, socket: &Path) -> anyhow::Result<()> {
}

#[cfg(windows)]
fn spawn_daemon(host: &HostInfo, socket: &Path) -> anyhow::Result<()> {
fn spawn_daemon(host: &HostInfo, socket: &Path, data_dir_arg: Option<&Path>) -> anyhow::Result<()> {
use std::os::windows::process::CommandExt;
use std::process::{Command, Stdio};

Expand All @@ -164,7 +178,9 @@ fn spawn_daemon(host: &HostInfo, socket: &Path) -> anyhow::Result<()> {
.split_first()
.ok_or_else(|| anyhow::anyhow!("empty serve_argv"))?;

let data_dir = crate::paths::data_dir(None);
let data_dir = data_dir_arg
.map(Path::to_path_buf)
.unwrap_or_else(|| crate::paths::data_dir(None));
let _ = crate::paths::ensure_private_dir(&data_dir);
let log = std::fs::OpenOptions::new()
.create(true)
Expand All @@ -175,6 +191,9 @@ fn spawn_daemon(host: &HostInfo, socket: &Path) -> anyhow::Result<()> {
let mut cmd = Command::new(exe);
cmd.args(rest);
cmd.arg("--socket").arg(socket);
if let Some(data_dir) = data_dir_arg {
cmd.arg("--data-dir").arg(data_dir);
}
cmd.stdin(Stdio::null());
match log {
Some(file) => {
Expand Down
Loading
Loading