Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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 @@ -371,6 +371,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 "BRAINTRUST_MUSE_BIN=$MUSE_INSTALL_DIR/muse" >> "$GITHUB_ENV"
- name: Install Rust
run: |
rustup toolchain install 1.97.1 --profile minimal
rustup default 1.97.1
- 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 @@ -381,6 +417,7 @@ jobs:
- pi-compatibility
- resolve-coding-agent-versions
- daemon
- muse-integration
runs-on: ubuntu-24.04
timeout-minutes: 5
if: always()
Expand Down
36 changes: 34 additions & 2 deletions bt-daemon/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion bt-daemon/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
name = "bt-daemon"
version = "0.1.0"
edition = "2021"
rust-version = "1.97"
license = "MIT"
publish = false
description = "Embeddable Braintrust coding-agent tracing daemon."
Expand All @@ -25,6 +26,7 @@ expect_used = "deny"
braintrust-sdk-rust = { git = "https://github.com/braintrustdata/braintrust-sdk-rust", rev = "45a2c88ca9d9ccc393ec0338ac438199fc3c5df2" }
base64 = "0.22"
anyhow = "1"
muse-code-sdk = "=1.4.12"
parking_lot = "0.12"
async-trait = "0.1"
chrono = "0.4"
Expand All @@ -34,7 +36,7 @@ regex = "1"
rquickjs = "0.12.2"
rquickjs-serde = "0.6.1"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = "1"
serde_json = { version = "1", features = ["raw_value"] }
semver = "1"
sha2 = "0.10"
sysinfo = { version = "0.33.1", default-features = false, features = ["system"] }
Expand Down
14 changes: 11 additions & 3 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 @@ -281,15 +283,21 @@ 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.

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
Muse Code 1.3+ supports live tracing, managed runs, session import, and attach
on macOS and Linux. Use `import muse --all` to import completed sessions.

Add `--attach` to keep following an active Codex, Claude, Antigravity, or Muse
session until Ctrl-C. Detaching from Muse leaves the session running; attach
again or import it later to collect its completion.

`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