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: 37 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,42 @@ jobs:
if: runner.os != 'macOS'
run: cargo clippy --manifest-path bt-daemon/Cargo.toml --all-targets --all-features --locked -- -D warnings

muse-integration:
name: Muse native integration (Linux)
env:
TBH_CREDENTIAL_BACKEND: file
TBH_DISABLE_TELEMETRY: "1"
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Install current Muse using its official installer
shell: bash
env:
MUSE_INSTALL_DIR: ${{ runner.temp }}/muse
MUSE_NO_MODIFY_PATH: "1"
run: |
curl --fail --silent --show-error --location https://dev.meta.ai/install.sh -o "$RUNNER_TEMP/install-muse.sh"
bash "$RUNNER_TEMP/install-muse.sh"
"$MUSE_INSTALL_DIR/muse" --version
echo "MUSE_BIN=$MUSE_INSTALL_DIR/muse" >> "$GITHUB_ENV"
- name: Install Rust
run: |
rustup toolchain install stable --profile minimal
rustup default stable
- name: Cache Rust build
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
workspaces: bt-daemon -> target
shared-key: muse-integration
save-if: ${{ github.event_name != 'pull_request' }}
- name: Trace native tools, retries, and failures through mock ingest
run: cargo test --manifest-path bt-daemon/Cargo.toml --all-features --locked --test muse_integration -- --ignored --nocapture
- name: Verify native active attach, detach, and resumed completion
run: cargo test --manifest-path bt-daemon/Cargo.toml --all-features --locked --test muse_attach -- --ignored --nocapture
- name: Verify native managed-hook coexistence and sanitized environments
run: cargo test --manifest-path bt-daemon/Cargo.toml --all-features --locked --test pipeline muse_managed_run -- --ignored --nocapture --test-threads=1

checks-passed:
name: checks-passed
needs:
Expand All @@ -414,6 +450,7 @@ jobs:
- pi-compatibility
- resolve-coding-agent-versions
- daemon
- muse-integration
runs-on: ubuntu-24.04
timeout-minutes: 5
if: always()
Expand Down
54 changes: 52 additions & 2 deletions bt-daemon/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ coding agent decides whether to continue its turn.
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 @@ -272,15 +274,63 @@ 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.3+ setup captures lifecycle, model, tool success/failure, permission
requests, subagent lifecycle, compaction, batch completion, and notifications.
Installation adds owned user hooks while preserving unrelated user hooks and
enterprise managed-hook policy. Disablement removes only Braintrust's hooks.

`import muse <session-id> --attach` follows an active session by polling full
exports with the same parser and translator used for historical import. Only
new envelopes are delivered. Active snapshots leave spans open; Ctrl-C detaches
without fabricating a terminal result or launching a final export. A later attach
or import applies authoritative completion to the same span IDs. Polling cost
grows with session length; MSP view events omit the model request/response records
needed by the translator.

Live model hooks and schema-v1 exports use the native turn, model step, and retry
attempt for shared span identity; the export's record UUID is retained as metadata.
Legacy exports without a model step retain their record UUID, so cross-ingress
model deduplication cannot be guaranteed for those records. Native response
previews remain previews: tracing cannot recover content Muse does not export.
Permission hooks report requests, not approval decisions; compaction hooks lack
native operation IDs. Captured built-in child hooks identify their child session
but omit the parent session, so their work remains in a separate trace rather
than guessing ancestry across concurrent sessions. Missing native facts are not invented.

`run muse` uses invocation-local `-c hooks=…` configuration and a private context
file because Muse sanitizes the hook environment. It replaces owned user hooks
for that invocation, preserves foreign user hooks and enterprise managed hooks,
and leaves saved settings unchanged. An isolated daemon starts before Muse so
authentication does not depend on the sanitized hook environment. Duplicate
Braintrust project hooks produce a diagnostic. Disable independently tracing
Muse plugins before running a managed session.

Muse native verification runs on Linux in CI and locally on macOS. The official
installer does not provide a Windows binary; Rust fixture tests still run on
Windows. Native tests use Muse's file credential backend
(`TBH_CREDENTIAL_BACKEND=file`) and disable telemetry so temporary profiles do
not trigger macOS Keychain creation dialogs. Muse hooks ship through the embedding `bt` binary; no separate marketplace
package is needed. After this PR merges, dispatch the consumer's existing
`Update bt-daemon dependency` workflow (`update-bt-daemon.yml` in
`braintrustdata/bt`) to prepare its Git pin and lockfile PR. Merge that consumer
update and build/install `bt`, then verify `bt trace setup
muse`, `bt trace run muse exec …`, `bt trace import muse <session-id>`, status,
and disablement against an isolated Muse profile. Release publishing remains
owned by the consumer repository.

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
3 changes: 3 additions & 0 deletions bt-daemon/src/agents/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ mod claude;
mod codex;
mod cursor;
mod grok;
mod muse;
mod opencode;
mod pi;

Expand All @@ -18,6 +19,7 @@ pub(crate) use claude::Claude;
pub(crate) use codex::Codex;
pub(crate) use cursor::Cursor;
pub(crate) use grok::Grok;
pub(crate) use muse::Muse;
pub(crate) use opencode::OpenCode;
pub(crate) use pi::Pi;

Expand Down Expand Up @@ -103,6 +105,7 @@ pub(crate) fn registrar() -> &'static Registrar {
Arc::new(Codex).register(&mut registrar);
Arc::new(Cursor).register(&mut registrar);
Arc::new(Grok).register(&mut registrar);
Arc::new(Muse).register(&mut registrar);
Arc::new(OpenCode).register(&mut registrar);
Arc::new(Pi).register(&mut registrar);
registrar
Expand Down
25 changes: 25 additions & 0 deletions bt-daemon/src/agents/muse.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
use super::{Agent, Identity, Register, Registrar};
use std::sync::Arc;

pub(crate) struct Muse;

pub(crate) const IDENTITY: Identity = Identity {
id: "muse",
source: "muse",
aliases: &["muse-code"],
display_name: "Muse Code",
};

impl Agent for Muse {
fn identity(&self) -> &'static Identity {
&IDENTITY
}
}

impl Register for Muse {
fn register(self: Arc<Self>, registrar: &mut Registrar) {
registrar.setup.insert(self.clone());
registrar.run.insert(self.clone());
registrar.translate.insert(self);
}
}
9 changes: 9 additions & 0 deletions bt-daemon/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ pub struct HookArgs {
/// not carry this flag and are suppressed for the managed child.
#[arg(long, hide = true)]
pub managed_run_hook: bool,
/// Private route context for Muse, whose hooks clear their environment.
#[arg(long, hide = true)]
pub managed_context: Option<PathBuf>,
}

/// Arguments for `status`.
Expand Down Expand Up @@ -179,6 +182,8 @@ pub struct ImportArgs {
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum ImportSource {
Codex,
#[value(alias = "muse-code")]
Muse,
Cursor,
#[value(name = "claude", alias = "claude-code")]
Claude,
Expand All @@ -199,6 +204,7 @@ impl ImportSource {
pub(crate) fn identity(self) -> &'static Identity {
match self {
Self::Codex => agents::Codex.identity(),
Self::Muse => agents::Muse.identity(),
Self::Cursor => agents::Cursor.identity(),
Self::Claude => agents::Claude.identity(),
Self::Antigravity => agents::Antigravity.identity(),
Expand All @@ -210,6 +216,7 @@ impl RunSource {
pub(crate) fn identity(self) -> &'static Identity {
match self {
Self::Codex => agents::Codex.identity(),
Self::Muse => agents::Muse.identity(),
Self::Cursor => agents::Cursor.identity(),
Self::Claude => agents::Claude.identity(),
Self::OpenCode => agents::OpenCode.identity(),
Expand Down Expand Up @@ -269,6 +276,8 @@ pub struct RunHookCommand {
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum RunSource {
Codex,
#[value(alias = "muse-code")]
Muse,
Cursor,
#[value(name = "claude", alias = "claude-code")]
Claude,
Expand Down
24 changes: 21 additions & 3 deletions bt-daemon/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,14 +151,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 @@ -172,13 +181,15 @@ pub async fn ensure_daemon(
anyhow::bail!("daemon did not come up at {}", socket.display())
}

fn spawn_daemon(host: &HostInfo, socket: &Path) -> anyhow::Result<()> {
fn spawn_daemon(host: &HostInfo, socket: &Path, data_dir_arg: Option<&Path>) -> anyhow::Result<()> {
let (exe, rest) = host
.serve_argv
.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 @@ -191,6 +202,10 @@ fn spawn_daemon(host: &HostInfo, socket: &Path) -> anyhow::Result<()> {
let mut args = rest.to_vec();
args.push("--socket".into());
args.push(socket.as_os_str().to_owned());
if let Some(data_dir) = data_dir_arg {
args.push("--data-dir".into());
args.push(data_dir.as_os_str().to_owned());
}
// A hook's stdout/stderr are pipes owned by the agent. Windows normally
// inherits every inheritable handle, even when the daemon's standard
// streams are redirected to serve.log. The daemon must not keep those
Expand All @@ -204,6 +219,9 @@ fn spawn_daemon(host: &HostInfo, socket: &Path) -> anyhow::Result<()> {
let mut cmd = crate::subprocess::detached_daemon_command(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 Down
Loading
Loading