diff --git a/.github/workflows/_release.yml b/.github/workflows/_release.yml index 46911e8..c38fbf4 100644 --- a/.github/workflows/_release.yml +++ b/.github/workflows/_release.yml @@ -34,6 +34,9 @@ on: OPENAI_API_KEY: description: "For the post-deploy codex smoke test; skipped if unset." required: false + CODEX_HOOK_TRUST_CONFIG: + description: "Reviewed Codex hook hashes for the deployed artifact; smoke skips if unset." + required: false permissions: contents: write diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99ce055..34e2ce3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -108,6 +108,7 @@ jobs: "@opencode-ai/sdk@${PEER_VERSION}" node -e 'import(process.argv[1]).then((m) => { if (typeof m.default !== "function") process.exit(1) })' \ "$PWD/install-test/node_modules/@braintrust/trace-opencode/dist/index.mjs" + (cd install-test && node -e 'import("@braintrust/trace-opencode/tracing").then((m) => { if (typeof m.default !== "function") process.exit(1) })') resolve-pi-compatibility: name: Resolve Pi compatibility window @@ -207,7 +208,7 @@ jobs: tarball="$(find "$package_dir" -name 'braintrust-trace-opencode-*.tgz' -print -quit)" npm install --prefix "$install_dir" --no-audit --no-fund \ "$tarball" @opencode-ai/plugin@latest @opencode-ai/sdk@latest - echo "OPENCODE_PLUGIN=$install_dir/node_modules/@braintrust/trace-opencode/dist/index.mjs" \ + echo "OPENCODE_PLUGIN=$install_dir/node_modules/@braintrust/trace-opencode/dist/tracing.mjs" \ >> "$GITHUB_ENV" - name: Build and install Pi extension for integration harness shell: bash diff --git a/.github/workflows/smoke-codex.yml b/.github/workflows/smoke-codex.yml index e25492b..0f8a944 100644 --- a/.github/workflows/smoke-codex.yml +++ b/.github/workflows/smoke-codex.yml @@ -17,6 +17,9 @@ on: required: true OPENAI_API_KEY: required: false + CODEX_HOOK_TRUST_CONFIG: + description: "Reviewed [hooks.state] TOML for the deployed hook definitions." + required: false workflow_dispatch: inputs: dist_repo: @@ -35,22 +38,23 @@ jobs: guard: runs-on: ubuntu-24.04 outputs: - has_key: ${{ steps.check.outputs.has_key }} + ready: ${{ steps.check.outputs.ready }} steps: - id: check env: KEY: ${{ secrets.OPENAI_API_KEY }} + TRUST: ${{ secrets.CODEX_HOOK_TRUST_CONFIG }} run: | - if [ -n "$KEY" ]; then - echo "has_key=true" >> "$GITHUB_OUTPUT" + if [ -n "$KEY" ] && [ -n "$TRUST" ]; then + echo "ready=true" >> "$GITHUB_OUTPUT" else - echo "has_key=false" >> "$GITHUB_OUTPUT" - echo "::warning::OPENAI_API_KEY not set; skipping codex smoke test." + echo "ready=false" >> "$GITHUB_OUTPUT" + echo "::warning::Codex smoke requires OPENAI_API_KEY and reviewed CODEX_HOOK_TRUST_CONFIG; skipping rather than bypassing hook trust." fi smoke: needs: guard - if: needs.guard.outputs.has_key == 'true' + if: needs.guard.outputs.ready == 'true' strategy: fail-fast: false matrix: @@ -72,7 +76,7 @@ jobs: - name: Install Codex and bt CLIs run: | npm install -g @openai/codex - curl -fsSL https://bt.dev/cli/install.sh | sh + curl -fsSL https://bt.dev/cli/install.sh | bash echo "$HOME/.local/bin" >> "$GITHUB_PATH" - name: Require daemon-capable bt @@ -87,6 +91,14 @@ jobs: codex plugin marketplace add "${{ inputs.dist_repo }}" codex plugin add "trace-codex@${MARKETPLACE}" + - name: Configure reviewed hook trust + env: + TRUST_CONFIG: ${{ secrets.CODEX_HOOK_TRUST_CONFIG }} + run: | + mkdir -p "$HOME/.codex" + printf '%s\n' "$TRUST_CONFIG" > "$HOME/.codex/config.toml" + chmod 600 "$HOME/.codex/config.toml" + - name: Run real traced Codex session (${{ matrix.label }}) env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} @@ -108,7 +120,6 @@ jobs: curl -fsS http://127.0.0.1:53999/version >/dev/null codex exec \ --skip-git-repo-check \ - --dangerously-bypass-hook-trust \ --sandbox read-only \ "say hi" python3 -c 'import json, os; s=json.load(open(os.environ["MOCK_COLLECTOR_OUT"])); assert s["totalRows"] >= 1, s; print(s)' diff --git a/AGENTS.md b/AGENTS.md index 0b8b9af..07bcd35 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,13 +1,13 @@ # Braintrust coding-agent plugins — monorepo This repo is the single source of truth for Braintrust's coding-agent plugins. -Each plugin is developed here, built, and deployed to a per-agent distribution -repository that its marketplace installs from. +Each integration is developed here and deployed through its native +marketplace or package registry. ## Layout ```text -src/plugins// one directory per agent (claude, codex) +src/plugins// one directory per agent (claude, codex, opencode, pi) content/ the deployable plugin tree, verbatim build.sh assemble the deployable tree into validate.sh validate manifests and required files @@ -18,9 +18,10 @@ Makefile build, test, and publish entry points .github/workflows/ CI and release automation ``` -Everything an agent installs lives under `src/plugins//content/`. -Plugin hooks are thin fail-open shell launchers that invoke -`bt trace hook`; they do not contain or compile a second tracing runtime. +Everything agent-specific lives under `src/plugins//content/`. +Claude and Codex hooks are rendered from one version-aware, fail-open shell +template and invoke `bt trace hook`; Pi and OpenCode share one generated +JavaScript daemon client. None contains a second tracing runtime. ## Adding a coding-agent integration @@ -49,8 +50,10 @@ release. Marketplace manifests are not versioned. |---|---| | claude | `braintrustdata/braintrust-claude-plugin` | | codex | `braintrustdata/braintrust-codex-plugin` | +| opencode | npm package `@braintrust/trace-opencode` | +| pi | npm package `@braintrust/pi-extension` | -A distribution repository is a generated artifact. Each deploy clones it, +A marketplace distribution repository is a generated artifact. Each deploy clones it, replaces the tracked tree with a fresh build, and pushes the result. `braintrustdata/test-coding-agent-dist` is the shared release sandbox. @@ -65,16 +68,23 @@ Cross-repository pushes use `GH_TOKEN` or ambient Git credentials. ## Releasing -The manual `release.yml` workflow deploys a production release, records the +For Claude and Codex, the manual `release.yml` workflow deploys a production release, records the version bump on `main`, tags it, and creates a GitHub Release. The manual `test-release.yml` workflow exercises the same deployment against the test repository without committing or tagging. Both call `_release.yml`. A Codex deployment can run `smoke-codex.yml`, which installs the deployed -plugin and runs a real Codex session through the daemon when -`OPENAI_API_KEY` is available. +plugin and runs a real Codex session through the daemon when `OPENAI_API_KEY` +and a reviewed, hash-scoped `CODEX_HOOK_TRUST_CONFIG` are available. It never +globally bypasses hook trust. CI installs the exact built npm artifacts and +runs deterministic real-agent integration tests for Pi and OpenCode; their +registry release workflows remain generated by sdk-actions. -CI builds and validates both plugin packages and builds, tests, and lints the +OpenCode and Pi use their generated `release-opencode.yml` and +`release-pi.yml` npm workflows. Do not hand-edit those sdk-actions-generated +files; update them through their generator. + +CI builds and validates all four integrations and builds, tests, and lints the Rust daemon on Linux, macOS, and Windows. Concurrent runs for an obsolete branch revision are cancelled. @@ -82,6 +92,7 @@ branch revision are cancelled. - `PUBLISH_TOKEN` grants `contents:write` on distribution repositories. - `OPENAI_API_KEY` enables the optional real Codex smoke test. +- `CODEX_HOOK_TRUST_CONFIG` enables only reviewed Codex hook hashes in that smoke test. Braintrust authentication is deliberately not stored in plugin or daemon settings. The embedding `bt` CLI owns profiles, OAuth, keychain access, API diff --git a/LICENSE b/LICENSE index dedad34..f8a1f4a 100644 --- a/LICENSE +++ b/LICENSE @@ -1 +1,21 @@ -TODO Apache 2.0 (placeholder) +MIT License + +Copyright (c) 2025 Braintrust Data, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile index b6b47b4..6a5fa2b 100644 --- a/Makefile +++ b/Makefile @@ -34,7 +34,7 @@ $(BUILD_RULES): build-%: @src/plugins/$*/build.sh "$(DIST)/$*" test: build - @for p in $(PLUGINS); do \ + @set -e; for p in $(PLUGINS); do \ echo "==> validate $$p"; \ src/plugins/$$p/validate.sh "$(DIST)/$$p"; \ done diff --git a/README.md b/README.md index aecd10e..b96faec 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Braintrust coding-agent plugins -A monorepo of various [Braintrust](https://braintrust.dev) coding agent plugins. +A monorepo of [Braintrust](https://braintrust.dev) coding-agent integrations. +Every integration forwards native events to the same local `bt` tracing daemon, +so authentication, routing, recovery, and trace construction stay consistent. For further instructions, see the instructions for your desired coding agent @@ -11,6 +13,21 @@ For further instructions, see the instructions for your desired coding agent | OpenCode | npm: [`@braintrust/trace-opencode`](https://www.npmjs.com/package/@braintrust/trace-opencode) | | Pi | npm: [`@braintrust/pi-extension`](https://www.npmjs.com/package/@braintrust/pi-extension) | +## Feature coverage + +| Agent | Persistent setup | Managed run | Import / attach | Braintrust tools | +|---|---:|---:|---:|---:| +| Claude Code | Yes | Yes | Yes / Yes | No | +| Codex | Yes | Yes | Yes / Yes | No | +| OpenCode | Yes | Yes | No / No | Yes | +| Pi | Yes | Yes | No / No | No | + +All four integrations support `bt trace enable`, `bt trace disable`, and +invocation-local `bt trace run`; `bt trace setup` remains an alias for enable. +Import and attach +are intentionally limited to agents whose native transcript stores preserve +the data needed by their daemon translators. + ## Development & releasing See [AGENTS.md](./AGENTS.md) for the repo structure, the build/deploy model, the diff --git a/bt-daemon/README.md b/bt-daemon/README.md index e5feabb..e99a558 100644 --- a/bt-daemon/README.md +++ b/bt-daemon/README.md @@ -5,9 +5,6 @@ stateful daemon that plugin **hook shims** forward events to; it owns the event→trace state machine and sends spans to Braintrust out-of-band. See [`docs/protocol.md`](docs/protocol.md) for the wire contract. -> **Placeholder name** — the real name is TBD. The subcommand framing -> (`serve` / `hook` / `status` / `import` / `run`) should survive a rename. - ## Layout One self-contained Cargo crate, liftable to its own repo by copying @@ -123,11 +120,12 @@ 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 -Ctrl-C. `run [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 -suppress inherited Braintrust plugin hooks to avoid logging the same session -twice; the injected hooks still use the normal daemon translator and sink. +Ctrl-C. `run [ARGS...]` launches the selected agent +with inherited stdio and injects live Braintrust capture for that invocation, +so it does not depend on persistent tracing being enabled. Managed runs +suppress or deduplicate inherited Braintrust capture to avoid logging the same +session twice; OpenCode uses the package's trace-only entrypoint so managed +tracing does not add its optional data tools. Codex applies its normal hook-review flow, so the first run requires trusting the injected Braintrust hook through `/hooks`; later runs reuse that trust while the hook definition remains unchanged. @@ -139,9 +137,10 @@ different profiles, organizations, projects, experiments, or parent spans. ## Status -Phases 0–5 are implemented: protocol, daemon lifecycle, Braintrust sink, -Codex and Claude translators, `bt daemon` integration, and thin hook shims for -both shipped plugins. Restart recovery replays the redacted journal with +The shared protocol, daemon lifecycle, Braintrust sink, all four production +translators, persistent setup, managed runs, and thin capture adapters are +implemented. Codex and Claude additionally support transcript import and live +attach. Restart recovery replays the redacted journal with deterministic span ids, so resubmitted rows merge into the same spans instead of creating duplicates. Claude lifecycle entries reference a daemon-owned transcript mirror, so recovery does not depend on mutable external paths @@ -156,8 +155,8 @@ journal, mirror, or conversation content — is capped or truncated; only in-memory caches are bounded, and each is re-derivable from disk. Windows named-pipe transport, detached spawning, lifecycle handover, and -cross-platform pipeline tests are implemented. The remaining host follow-ups -are OpenCode and pi, which are not present in this monorepo. +cross-platform pipeline tests are implemented. Pi and OpenCode use the same +long-lived JavaScript daemon client on every supported platform. - The Braintrust sink pins `braintrust-sdk-rust` commit `d33e806`, which adds deterministic span ids, `span_origin`/`span_attributes` passthrough, and diff --git a/bt-daemon/src/command_output.rs b/bt-daemon/src/command_output.rs index 704160d..fecb4a7 100644 --- a/bt-daemon/src/command_output.rs +++ b/bt-daemon/src/command_output.rs @@ -5,6 +5,7 @@ //! consistently and JSON mode never falls back to human prose. use crate::wire::StatusResult; +use crate::AgentSpec; use serde::Serialize; use std::path::PathBuf; @@ -61,6 +62,9 @@ pub struct SetupCommandOutput { pub restart_required: bool, } +/// Enable and disable report the same stable agent lifecycle shape. +pub type LifecycleCommandOutput = SetupCommandOutput; + #[derive(Debug, Clone, Serialize)] pub struct StopCommandOutput { pub running: bool, @@ -72,7 +76,7 @@ pub struct StopCommandOutput { pub enum TraceCommandOutput { Status(StatusCommandOutput), Enable(SetupCommandOutput), - Disable(SetupCommandOutput), + Disable(LifecycleCommandOutput), Stop(StopCommandOutput), } @@ -81,36 +85,28 @@ impl TraceCommandOutput { Self::Status(status.into()) } - pub fn setup( - source: impl Into, - display_name: impl Into, - settings_path: impl Into, - ) -> Self { + pub fn setup(spec: &AgentSpec, settings_path: impl Into) -> Self { Self::Enable(SetupCommandOutput { - source: source.into(), - display_name: display_name.into(), + source: spec.canonical_source.into(), + display_name: spec.display_name.into(), settings_path: settings_path.into(), restart_required: true, }) } - pub fn stop(running: bool, stopped: bool) -> Self { - Self::Stop(StopCommandOutput { running, stopped }) - } - - pub fn disable( - source: impl Into, - display_name: impl Into, - settings_path: impl Into, - ) -> Self { + pub fn disable(spec: &AgentSpec, settings_path: impl Into) -> Self { Self::Disable(SetupCommandOutput { - source: source.into(), - display_name: display_name.into(), + source: spec.canonical_source.into(), + display_name: spec.display_name.into(), settings_path: settings_path.into(), restart_required: true, }) } + pub fn stop(running: bool, stopped: bool) -> Self { + Self::Stop(StopCommandOutput { running, stopped }) + } + pub fn render(&self, format: OutputFormat) -> anyhow::Result { match format { OutputFormat::Json => Ok(serde_json::to_string(self)?), @@ -161,8 +157,7 @@ mod tests { #[test] fn enable_json_contains_stable_selection_fields_without_prose() { let output = TraceCommandOutput::setup( - "opencode", - "OpenCode", + crate::AgentId::OpenCode.spec(), PathBuf::from("/tmp/opencode/braintrust.json"), ); let rendered = output.render(OutputFormat::Json).unwrap(); @@ -206,6 +201,20 @@ mod tests { ); } + #[test] + fn lifecycle_json_uses_canonical_source_names() { + let path = PathBuf::from("/tmp/claude/braintrust.json"); + let disabled: serde_json::Value = serde_json::from_str( + &TraceCommandOutput::disable(crate::AgentId::Claude.spec(), path) + .render(OutputFormat::Json) + .unwrap(), + ) + .unwrap(); + assert_eq!(disabled["command"], "disable"); + assert_eq!(disabled["source"], "claude-code"); + assert_eq!(disabled["restart_required"], true); + } + #[test] fn human_output_preserves_existing_messages() { assert_eq!( diff --git a/bt-daemon/src/dispatch.rs b/bt-daemon/src/dispatch.rs index d065898..a3161c8 100644 --- a/bt-daemon/src/dispatch.rs +++ b/bt-daemon/src/dispatch.rs @@ -153,19 +153,14 @@ impl Session { } } -/// Claude transcript files are external mutable state. Mirror them into +/// Agent transcript files are external mutable state. Mirror them into /// daemon-owned storage at lifecycle boundaries and journal only a reference, -/// so recovery/replay does not depend on a path that Claude may later rewrite +/// so recovery/replay does not depend on a path that an agent may later rewrite /// or delete — and so the transcript is stored once rather than re-copied into /// every event. Fail open: without a reference the translator reads the live /// path exactly as before. pub(crate) async fn hydrate_transcript_reference(data_dir: &std::path::Path, env: &mut Envelope) { - if env.source != "claude-code" - || !matches!( - env.event.as_str(), - "UserPromptSubmit" | "Stop" | "StopFailure" | "SubagentStop" | "SessionEnd" - ) - { + if !matches!(env.source.as_str(), "claude-code" | "codex") { return; } let field = if env.event == "SubagentStop" { @@ -181,8 +176,9 @@ pub(crate) async fn hydrate_transcript_reference(data_dir: &std::path::Path, env else { return; }; + let mirror_session = crate::ids::session_namespace(&env.source, &env.session_id); let (mirror, through) = - match crate::transcript_mirror::capture(data_dir, &env.session_id, &path).await { + match crate::transcript_mirror::capture(data_dir, &mirror_session, &path).await { Ok(captured) => captured, Err(error) => { tracing::debug!(session_id = %env.session_id, %error, "transcript mirror skipped"); @@ -216,7 +212,16 @@ struct SessionActor { impl SessionActor { async fn run(self, mut rx: mpsc::Receiver) { - let mut translator = self.translators.create(&self.source, &self.session_id); + let mut translator = match self + .translators + .create_checked(&self.source, &self.session_id) + { + Ok(translator) => translator, + Err(error) => { + self.set_error(format!("translator init failed: {error}")); + return; + } + }; let mut sink = match self.sink_factory.create( &self.session_id, &self.source, @@ -285,11 +290,13 @@ impl SessionActor { let _ = reply.send(()); } SessionMsg::Flush(reply) => { - self.drain_flush(&mut translator, &mut sink, &ctx).await; + self.checkpoint_and_flush(&mut translator, &mut sink, &ctx) + .await; let _ = reply.send(self.counters.queued.load(Ordering::Relaxed)); } SessionMsg::Shutdown(reply) => { - self.drain_flush(&mut translator, &mut sink, &ctx).await; + self.finalize_and_flush(&mut translator, &mut sink, &ctx) + .await; let _ = reply.send(()); break; } @@ -375,6 +382,9 @@ impl SessionActor { continue; } let env = crate::journal::envelope_from_redacted(entry); + if env.source != self.source { + continue; + } let translated = translator.handle(&env, ctx); self.emit_translator_batches( translator, @@ -388,22 +398,45 @@ impl SessionActor { } } - async fn drain_flush( + async fn checkpoint_and_flush( + &self, + translator: &mut Box, + sink: &mut Box, + ctx: &SessionCtx, + ) { + let translated = translator.checkpoint(ctx); + self.emit_translator_batches( + translator, + sink, + ctx, + translated, + "translate checkpoint failed", + "sink emit (checkpoint) failed", + ) + .await; + self.flush_sink(sink).await; + } + + async fn finalize_and_flush( &self, translator: &mut Box, sink: &mut Box, ctx: &SessionCtx, ) { - let translated = translator.flush(ctx); + let translated = translator.finalize(ctx); self.emit_translator_batches( translator, sink, ctx, translated, - "translate flush failed", - "sink emit (flush) failed", + "translate finalization failed", + "sink emit (finalization) failed", ) .await; + self.flush_sink(sink).await; + } + + async fn flush_sink(&self, sink: &mut Box) { if let Err(e) = sink.flush().await { self.set_error(format!("sink flush failed: {e}")); } diff --git a/bt-daemon/src/ids.rs b/bt-daemon/src/ids.rs index 6167207..edc520f 100644 --- a/bt-daemon/src/ids.rs +++ b/bt-daemon/src/ids.rs @@ -14,6 +14,25 @@ const NAMESPACE: Uuid = Uuid::from_u128(0x8f2b_4e11_9c7a_4d3e_b6a1_5f0c_2d84_71a const SEP: char = '\u{1f}'; // ASCII unit separator; will not appear in ids/keys. +/// A stable namespace for one agent's native session. Source qualification +/// prevents two agents that happen to choose the same native session id from +/// sharing span ids, actor locks, or journals. +pub fn session_namespace(source: &str, session_id: &str) -> String { + format!("{source}{SEP}{session_id}") +} + +pub fn native_session_id(namespace: &str) -> &str { + namespace + .split_once(SEP) + .map(|(_, session_id)| session_id) + .unwrap_or(namespace) +} + +/// Collision-resistant suffix for source-qualified on-disk session state. +pub fn session_storage_id(source: &str, session_id: &str) -> String { + Uuid::new_v5(&NAMESPACE, session_namespace(source, session_id).as_bytes()).to_string() +} + /// A deterministic span id for `key` within `session_id`. `key` should encode /// the logical span identity, e.g. `turn:{turn_id}` or `tool:{call_id}`. pub fn span_id(session_id: &str, key: &str) -> String { @@ -35,4 +54,15 @@ mod tests { assert_ne!(a1, b); assert_ne!(a1, c); } + + #[test] + fn source_qualified_sessions_do_not_collide() { + let codex = session_namespace("codex", "same"); + let claude = session_namespace("claude-code", "same"); + assert_ne!(span_id(&codex, "root"), span_id(&claude, "root")); + assert_ne!( + session_storage_id("codex", "same"), + session_storage_id("claude-code", "same") + ); + } } diff --git a/bt-daemon/src/journal.rs b/bt-daemon/src/journal.rs index 17c71e7..7eeb4a7 100644 --- a/bt-daemon/src/journal.rs +++ b/bt-daemon/src/journal.rs @@ -3,7 +3,7 @@ //! rebuild session state by replaying the journal through the translator. //! //! Format: one [`RedactedEnvelope`] JSON value per line in -//! `/journal/.ndjson`. +//! `/journal/--.ndjson`. //! //! Managed-run acceptance records live alongside the journals so a flush can //! still tell which delivery pipelines a managed child produced after the @@ -35,6 +35,45 @@ pub fn journal_path(data_dir: &Path, session_id: &str) -> PathBuf { journal_dir(data_dir).join(format!("{}.ndjson", sanitize(session_id))) } +/// Source-qualified journal path. The stable suffix prevents sanitized native +/// ids such as `a/b` and `a_b` from aliasing the same file. +pub fn source_journal_path(data_dir: &Path, source: &str, session_id: &str) -> PathBuf { + journal_dir(data_dir).join(format!( + "{}--{}--{}.ndjson", + sanitize(source), + sanitize(session_id), + crate::ids::session_storage_id(source, session_id) + )) +} + +/// Return the source-qualified journal, copying the legacy session-only file +/// on first use so an upgrade retains replay history. The legacy file remains +/// untouched for rollback and is no longer appended after migration. +pub async fn ensure_source_journal( + data_dir: &Path, + source: &str, + session_id: &str, +) -> anyhow::Result { + let path = source_journal_path(data_dir, source, session_id); + if tokio::fs::metadata(&path).await.is_ok() { + return Ok(path); + } + let legacy = journal_path(data_dir, session_id); + match tokio::fs::metadata(&legacy).await { + Ok(_) => { + tokio::fs::create_dir_all(journal_dir(data_dir)).await?; + match tokio::fs::copy(&legacy, &path).await { + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(error.into()), + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + Ok(path) +} + pub fn managed_run_dir(data_dir: &Path) -> PathBuf { data_dir.join("managed-runs") } @@ -46,6 +85,9 @@ pub fn managed_run_path(data_dir: &Path, managed_run_id: &str) -> PathBuf { /// One delivery pipeline accepted from a managed child process tree. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ManagedRunKey { + /// Missing in records written before source-qualified delivery keys. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, pub session_id: String, pub route: SessionRoute, } @@ -93,6 +135,29 @@ pub async fn read_managed_run_keys(data_dir: &Path, managed_run_id: &str) -> Vec keys } +/// Recover the source omitted by pre-source-qualified managed-run records. +/// Route matching avoids selecting an unrelated delivery pipeline when an old +/// session journal contains more than one destination. +pub async fn legacy_journal_source( + data_dir: &Path, + session_id: &str, + route: &SessionRoute, +) -> Option { + let path = journal_path(data_dir, session_id); + let through = JournalReader::recorded_len(&path).await; + let mut reader = JournalReader::open(&path, through).await.ok().flatten()?; + while let Ok(Some(entry)) = reader.next_entry().await { + if entry + .route + .as_ref() + .is_some_and(|candidate| candidate.same_route(route)) + { + return Some(entry.source); + } + } + None +} + /// Best-effort age-based collection of managed-run records, mirroring journal /// GC. pub async fn gc_old_managed_runs(data_dir: &Path, max_age: std::time::Duration) { @@ -125,14 +190,14 @@ pub struct JournalWriter { } impl JournalWriter { - pub async fn open(data_dir: &Path, session_id: &str) -> anyhow::Result { - let dir = journal_dir(data_dir); - tokio::fs::create_dir_all(&dir).await?; - let path = journal_path(data_dir, session_id); + pub async fn open_path(path: &Path) -> anyhow::Result { + if let Some(dir) = path.parent() { + tokio::fs::create_dir_all(dir).await?; + } let file = tokio::fs::OpenOptions::new() .create(true) .append(true) - .open(&path) + .open(path) .await?; Ok(Self { file }) } @@ -267,3 +332,57 @@ pub fn envelope_from_redacted(r: RedactedEnvelope) -> Envelope { config, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn source_journals_are_distinct_and_migrate_legacy_history() { + let temp = tempfile::tempdir().unwrap(); + let legacy = journal_path(temp.path(), "same/session"); + tokio::fs::create_dir_all(legacy.parent().unwrap()) + .await + .unwrap(); + tokio::fs::write(&legacy, b"legacy\n").await.unwrap(); + + let codex = ensure_source_journal(temp.path(), "codex", "same/session") + .await + .unwrap(); + let claude = ensure_source_journal(temp.path(), "claude-code", "same/session") + .await + .unwrap(); + assert_ne!(codex, claude); + assert_eq!(tokio::fs::read(&codex).await.unwrap(), b"legacy\n"); + assert_eq!(tokio::fs::read(&claude).await.unwrap(), b"legacy\n"); + assert_eq!(tokio::fs::read(&legacy).await.unwrap(), b"legacy\n"); + } + + #[tokio::test] + async fn legacy_managed_run_records_recover_source_from_the_old_journal() { + let temp = tempfile::tempdir().unwrap(); + let route = SessionRoute::default(); + let mut writer = JournalWriter::open_path(&journal_path(temp.path(), "legacy")) + .await + .unwrap(); + writer + .append(&Envelope { + source: "codex".into(), + source_version: None, + plugin_version: None, + session_id: "legacy".into(), + event: "SessionStart".into(), + ts_ms: 1, + managed_run_id: None, + payload: serde_json::json!({}), + route: Some(route.clone()), + config: None, + }) + .await + .unwrap(); + assert_eq!( + legacy_journal_source(temp.path(), "legacy", &route).await, + Some("codex".into()) + ); + } +} diff --git a/bt-daemon/src/lib.rs b/bt-daemon/src/lib.rs index 762a8ad..bb50db3 100644 --- a/bt-daemon/src/lib.rs +++ b/bt-daemon/src/lib.rs @@ -31,8 +31,11 @@ mod transport; pub mod wire; pub use client::HostInfo; pub use command_output::{ - OutputFormat, SetupCommandOutput, StatusCommandOutput, StopCommandOutput, TraceCommandOutput, + LifecycleCommandOutput, OutputFormat, SetupCommandOutput, StatusCommandOutput, + StopCommandOutput, TraceCommandOutput, }; +#[doc(hidden)] +pub use journal::source_journal_path; pub use server::{AuthLease, AuthProvider, AuthResolveReason, ServeOptions}; pub use setup::{run_disable, run_enable, run_setup}; pub use sink::{BraintrustSinkConfig, BraintrustSinkFactory, DebugSinkFactory, Sink, SinkFactory}; @@ -59,6 +62,219 @@ use wire::{ const MANAGED_RUN_ID_ENV: &str = "BT_TRACE_MANAGED_RUN_ID"; const MANAGED_RUN_FLUSH_TIMEOUT_MS: u64 = 10_000; +const CODEX_HOOK_EVENTS: &[&str] = &[ + "PermissionRequest", + "PostCompact", + "PostToolUse", + "PreCompact", + "PreToolUse", + "SessionEnd", + "SessionStart", + "Stop", + "SubagentStart", + "SubagentStop", + "UserPromptSubmit", +]; + +const CLAUDE_HOOK_EVENTS: &[&str] = &[ + "ConfigChange", + "CwdChanged", + "Elicitation", + "ElicitationResult", + "FileChanged", + "InstructionsLoaded", + "MessageDisplay", + "Notification", + "PermissionDenied", + "PermissionRequest", + "PostCompact", + "PostToolBatch", + "PostToolUse", + "PostToolUseFailure", + "PreCompact", + "PreToolUse", + "SessionEnd", + "SessionStart", + "Setup", + "Stop", + "StopFailure", + "SubagentStart", + "SubagentStop", + "TaskCompleted", + "TaskCreated", + "TeammateIdle", + "UserPromptExpansion", + "UserPromptSubmit", + "WorktreeCreate", + "WorktreeRemove", +]; + +/// Stable identity for every production coding-agent integration. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)] +pub enum AgentId { + Codex, + #[value(name = "claude", alias = "claude-code")] + Claude, + #[value(name = "opencode", alias = "open-code")] + OpenCode, + Pi, +} + +/// Native location used for one agent's non-secret Braintrust settings. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AgentSettingsLocation { + Codex, + Claude, + OpenCode, + Pi, +} + +/// User-visible feature coverage declared by an integration. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AgentCapabilities { + pub setup: bool, + pub managed_run: bool, + pub transcript_import: bool, + pub transcript_attach: bool, + pub data_tools: bool, +} + +/// Static facts shared by CLI parsing, setup, managed runs, and translators. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AgentSpec { + pub id: AgentId, + pub canonical_source: &'static str, + pub aliases: &'static [&'static str], + pub display_name: &'static str, + pub executable_env: &'static str, + pub executable: &'static str, + pub settings_location: AgentSettingsLocation, + pub setup_package: &'static str, + pub managed_package: &'static str, + pub uninstall_package: &'static str, + pub hook_events: &'static [&'static str], + pub capabilities: AgentCapabilities, +} + +const AGENT_IDS: &[AgentId] = &[ + AgentId::Codex, + AgentId::Claude, + AgentId::OpenCode, + AgentId::Pi, +]; + +const CODEX_SPEC: AgentSpec = AgentSpec { + id: AgentId::Codex, + canonical_source: "codex", + aliases: &[], + display_name: "Codex", + executable_env: "CODEX_BIN", + executable: "codex", + settings_location: AgentSettingsLocation::Codex, + setup_package: "trace-codex@braintrust-codex-plugins", + managed_package: "", + uninstall_package: "trace-codex@braintrust-codex-plugins", + hook_events: CODEX_HOOK_EVENTS, + capabilities: AgentCapabilities { + setup: true, + managed_run: true, + transcript_import: true, + transcript_attach: true, + data_tools: false, + }, +}; + +const CLAUDE_SPEC: AgentSpec = AgentSpec { + id: AgentId::Claude, + canonical_source: "claude-code", + aliases: &["claude"], + display_name: "Claude Code", + executable_env: "CLAUDE_BIN", + executable: "claude", + settings_location: AgentSettingsLocation::Claude, + setup_package: "trace-claude-code@braintrust-claude-plugin", + managed_package: "", + uninstall_package: "trace-claude-code@braintrust-claude-plugin", + hook_events: CLAUDE_HOOK_EVENTS, + capabilities: AgentCapabilities { + setup: true, + managed_run: true, + transcript_import: true, + transcript_attach: true, + data_tools: false, + }, +}; + +const OPENCODE_SPEC: AgentSpec = AgentSpec { + id: AgentId::OpenCode, + canonical_source: "opencode", + aliases: &["open-code"], + display_name: "OpenCode", + executable_env: "OPENCODE_BIN", + executable: "opencode", + settings_location: AgentSettingsLocation::OpenCode, + setup_package: "@braintrust/trace-opencode@^1", + managed_package: "@braintrust/trace-opencode/tracing", + uninstall_package: "@braintrust/trace-opencode", + hook_events: &[], + capabilities: AgentCapabilities { + setup: true, + managed_run: true, + transcript_import: false, + transcript_attach: false, + data_tools: true, + }, +}; + +const PI_SPEC: AgentSpec = AgentSpec { + id: AgentId::Pi, + canonical_source: "pi", + aliases: &[], + display_name: "Pi", + executable_env: "PI_BIN", + executable: "pi", + settings_location: AgentSettingsLocation::Pi, + setup_package: "npm:@braintrust/pi-extension@^1", + managed_package: "npm:@braintrust/pi-extension@^1", + uninstall_package: "npm:@braintrust/pi-extension", + hook_events: &[], + capabilities: AgentCapabilities { + setup: true, + managed_run: true, + transcript_import: false, + transcript_attach: false, + data_tools: false, + }, +}; + +impl AgentId { + pub fn all() -> &'static [Self] { + AGENT_IDS + } + + /// Resolve canonical daemon identities and supported public aliases. + pub fn parse(source: &str) -> Option { + let source = source.trim().to_ascii_lowercase(); + Self::all().iter().copied().find(|agent| { + let spec = agent.spec(); + source == spec.canonical_source || spec.aliases.contains(&source.as_str()) + }) + } + + pub const fn spec(self) -> &'static AgentSpec { + match self { + Self::Codex => &CODEX_SPEC, + Self::Claude => &CLAUDE_SPEC, + Self::OpenCode => &OPENCODE_SPEC, + Self::Pi => &PI_SPEC, + } + } + + pub const fn canonical_source(self) -> &'static str { + self.spec().canonical_source + } +} + /// Arguments for `serve`. #[derive(Debug, Clone, Args)] pub struct ServeArgs { @@ -88,6 +304,9 @@ pub struct HookArgs { /// Optional agent version, forwarded for payload-drift handling. #[arg(long)] pub source_version: Option, + /// Version of the Braintrust capture adapter forwarding this event. + #[arg(long)] + pub plugin_version: Option, /// Socket path override. #[arg(long)] pub socket: Option, @@ -172,6 +391,21 @@ pub enum ImportSource { Claude, } +impl ImportSource { + pub const fn agent_id(self) -> AgentId { + match self { + Self::Codex => AgentId::Codex, + Self::Claude => AgentId::Claude, + } + } +} + +impl From for AgentId { + fn from(source: ImportSource) -> Self { + source.agent_id() + } +} + /// Arguments for launching a coding agent with invocation-local live hooks. #[derive(Debug, Clone, Args)] #[command(trailing_var_arg = true)] @@ -197,14 +431,29 @@ pub struct RunHookCommand { pub args: Vec, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] -pub enum RunSource { - Codex, - #[value(name = "claude", alias = "claude-code")] - Claude, - #[value(name = "opencode", alias = "open-code")] - OpenCode, - Pi, +/// Backwards-compatible name for the shared coding-agent identity. +pub type RunSource = AgentId; + +/// Typed failure returned when a managed coding agent exits unsuccessfully. +/// Embedding CLIs can downcast this error and preserve the child's exact code. +#[derive(Debug, thiserror::Error)] +#[error("coding agent exited with {status}")] +pub struct ManagedRunExitError { + status: std::process::ExitStatus, +} + +impl ManagedRunExitError { + pub fn new(status: std::process::ExitStatus) -> Self { + Self { status } + } + + pub fn status(&self) -> std::process::ExitStatus { + self.status + } + + pub fn code(&self) -> Option { + self.status.code() + } } /// Run the daemon until shutdown. @@ -229,7 +478,13 @@ pub async fn run_hook( if std::env::var_os("_BT_TRACE_MANAGED_RUN").is_some() && !args.managed_run_hook { return Ok(()); } - let settings = settings::AgentSettings::load(&args.source); + let source = match AgentId::parse(&args.source) { + Some(agent) => agent.canonical_source(), + // `debug` remains an explicit development-only translator identity. + None if args.source == "debug" => "debug", + None => anyhow::bail!("unsupported coding-agent source {:?}", args.source), + }; + let settings = settings::AgentSettings::load(source); if !settings.tracing_enabled() { return Ok(()); } @@ -251,9 +506,9 @@ pub async fn run_hook( } apply_additional_metadata(&mut route, args.additional_metadata.as_deref())?; let env = Envelope { - source: args.source.clone(), + source: source.to_string(), source_version: args.source_version.clone(), - plugin_version: None, + plugin_version: args.plugin_version.clone(), session_id, event, ts_ms: now_ms(), @@ -317,7 +572,7 @@ pub async fn forward_envelope( "protocol_version": PROTOCOL_VERSION, "client": { "source": env.source, - "plugin_version": env.source_version, + "plugin_version": env.plugin_version, "pid": std::process::id() } }), @@ -349,7 +604,7 @@ pub async fn forward_envelope( "protocol_version": PROTOCOL_VERSION, "client": { "source": env.source, - "plugin_version": env.source_version, + "plugin_version": env.plugin_version, "pid": std::process::id() } }), @@ -500,14 +755,9 @@ pub async fn run_traced( "managed run requires a trace destination; select a project, object destination, or parent span" ); } - let (executable_env, default_executable) = match args.source { - RunSource::Codex => ("CODEX_BIN", "codex"), - RunSource::Claude => ("CLAUDE_BIN", "claude"), - RunSource::OpenCode => ("OPENCODE_BIN", "opencode"), - RunSource::Pi => ("PI_BIN", "pi"), - }; + let spec = args.source.spec(); let executable = - std::env::var_os(executable_env).unwrap_or_else(|| OsString::from(default_executable)); + std::env::var_os(spec.executable_env).unwrap_or_else(|| OsString::from(spec.executable)); let injected_args = managed_run_args(args.source, &hook_command)?; let managed_run_id = uuid::Uuid::new_v4().to_string(); let invocation_settings = serde_json::to_string(&settings::InvocationSettings::enabled(route))?; @@ -517,7 +767,12 @@ pub async fn run_traced( .args(args.agent_args) .env("_BT_TRACE_MANAGED_RUN", "1") .env(MANAGED_RUN_ID_ENV, &managed_run_id) - .env(settings::INVOCATION_SETTINGS_ENV, invocation_settings); + .env("BT_TRACE_CAPTURE_MODE", "managed") + .env(settings::INVOCATION_SETTINGS_ENV, invocation_settings) + // The parent has already resolved the public environment variable into + // the invocation route. Do not let a child hook re-apply it and defeat + // an explicit `bt trace run --additional-metadata` override. + .env_remove("BRAINTRUST_ADDITIONAL_METADATA"); if args.source == RunSource::OpenCode { command.env( "OPENCODE_CONFIG_CONTENT", @@ -566,12 +821,7 @@ fn managed_run_args( source: RunSource, hook_command: &RunHookCommand, ) -> anyhow::Result> { - let source_name = match source { - RunSource::Codex => "codex", - RunSource::Claude => "claude", - RunSource::OpenCode => "opencode", - RunSource::Pi => "pi", - }; + let source_name = source.canonical_source(); match source { RunSource::Codex | RunSource::Claude => { let unix_command = managed_hook_shell_command(hook_command, source_name, false)?; @@ -590,7 +840,7 @@ fn managed_run_args( RunSource::Pi => Ok(vec![ OsString::from("-e"), std::env::var_os("BT_TRACE_PI_PLUGIN_SPEC") - .unwrap_or_else(|| OsString::from("npm:@braintrust/pi-extension@^1")), + .unwrap_or_else(|| OsString::from(source.spec().managed_package)), ]), } } @@ -610,7 +860,7 @@ fn opencode_managed_config(existing: Option<&str>) -> anyhow::Result { .as_array_mut() .ok_or_else(|| anyhow::anyhow!("OPENCODE_CONFIG_CONTENT.plugin must be an array"))?; let plugin = std::env::var("BT_TRACE_OPENCODE_PLUGIN_SPEC") - .unwrap_or_else(|_| "@braintrust/trace-opencode@^1".to_string()); + .unwrap_or_else(|_| AgentId::OpenCode.spec().managed_package.to_string()); if !plugins.iter().any(|value| value.as_str() == Some(&plugin)) { plugins.push(serde_json::Value::String(plugin)); } @@ -650,50 +900,12 @@ fn quote_windows_command_arg(arg: &str) -> String { format!("\"{}\"", arg.replace('\\', "/").replace('"', "\"\"")) } -const CODEX_RUN_HOOK_EVENTS: &[&str] = &[ - "SessionStart", - "UserPromptSubmit", - "PreToolUse", - "PermissionRequest", - "PostToolUse", - "PreCompact", - "PostCompact", - "SubagentStart", - "SubagentStop", - "Stop", - "SessionEnd", -]; - -const CLAUDE_RUN_HOOK_EVENTS: &[&str] = &[ - "SessionStart", - "Setup", - "UserPromptSubmit", - "UserPromptExpansion", - "PreToolUse", - "PermissionRequest", - "PermissionDenied", - "PostToolUse", - "PostToolUseFailure", - "PostToolBatch", - "PreCompact", - "PostCompact", - "Notification", - "MessageDisplay", - "SubagentStart", - "SubagentStop", - "TaskCreated", - "TaskCompleted", - "Stop", - "StopFailure", - "SessionEnd", -]; - fn codex_managed_run_args(unix_command: &str, windows_command: &str) -> Vec { let unix_command = serde_json::to_string(unix_command).expect("serialize hook command"); let windows_command = serde_json::to_string(windows_command).expect("serialize Windows hook command"); let mut args = vec![OsString::from("--enable"), OsString::from("hooks")]; - for event in CODEX_RUN_HOOK_EVENTS { + for event in AgentId::Codex.spec().hook_events { args.push(OsString::from("-c")); args.push(OsString::from(format!( "hooks.{event}=[{{hooks=[{{type=\"command\",command={unix_command},commandWindows={windows_command}}}]}}]" @@ -712,7 +924,9 @@ fn claude_managed_run_args(command: &str) -> anyhow::Result> { }] }] }); - let hooks = CLAUDE_RUN_HOOK_EVENTS + let hooks = AgentId::Claude + .spec() + .hook_events .iter() .map(|event| ((*event).to_string(), hook.clone())) .collect::>(); @@ -1116,7 +1330,7 @@ mod tests { .any(|arg| arg == "--dangerously-bypass-hook-trust")); assert_eq!( args.iter().filter(|arg| *arg == "-c").count(), - CODEX_RUN_HOOK_EVENTS.len() + AgentId::Codex.spec().hook_events.len() ); let config = args .iter() @@ -1139,7 +1353,7 @@ mod tests { assert_eq!(args[0], "--settings"); let settings: serde_json::Value = serde_json::from_str(args[1].to_str().unwrap()).unwrap(); let hooks = settings["hooks"].as_object().unwrap(); - assert_eq!(hooks.len(), CLAUDE_RUN_HOOK_EVENTS.len()); + assert_eq!(hooks.len(), AgentId::Claude.spec().hook_events.len()); let command = hooks["SessionStart"]["hooks"][0]["hooks"][0]["command"] .as_str() .unwrap(); @@ -1147,7 +1361,7 @@ mod tests { assert!(command.contains("agents")); assert!(command.contains("hook")); assert!(command.contains("--source")); - assert!(command.contains("claude")); + assert!(command.contains("claude-code")); assert!(!command.contains("transcript")); } @@ -1159,7 +1373,7 @@ mod tests { assert_eq!(config["model"], "test/model"); assert_eq!( config["plugin"], - serde_json::json!(["other", "@braintrust/trace-opencode@^1"]) + serde_json::json!(["other", "@braintrust/trace-opencode/tracing"]) ); assert!( managed_run_args(RunSource::OpenCode, &test_run_hook_command()) @@ -1181,8 +1395,48 @@ mod tests { let hook = test_run_hook_command(); let unix = managed_hook_shell_command(&hook, "codex", false).unwrap(); assert!(unix.contains("'/opt/Braintrust CLI/bt' 'agents' 'hook' '--source' 'codex'")); - let windows = managed_hook_shell_command(&hook, "claude", true).unwrap(); - assert!(windows - .contains("\"/opt/Braintrust CLI/bt\" \"agents\" \"hook\" \"--source\" \"claude\"")); + let windows = + managed_hook_shell_command(&hook, AgentId::Claude.canonical_source(), true).unwrap(); + assert!(windows.contains( + "\"/opt/Braintrust CLI/bt\" \"agents\" \"hook\" \"--source\" \"claude-code\"" + )); + } + + #[test] + fn managed_hook_commands_preserve_unicode_paths() { + let hook = RunHookCommand { + program: OsString::from("/opt/Braintrust 🧠/bt"), + args: vec![OsString::from("trace"), OsString::from("hook")], + }; + + let unix = managed_hook_shell_command(&hook, "codex", false).unwrap(); + let windows = managed_hook_shell_command(&hook, "codex", true).unwrap(); + + assert!(unix.contains("'/opt/Braintrust 🧠/bt'")); + assert!(windows.contains("\"/opt/Braintrust 🧠/bt\"")); + } + + #[test] + fn agent_catalog_canonicalizes_aliases_and_declares_capabilities() { + assert_eq!(AgentId::parse("claude"), Some(AgentId::Claude)); + assert_eq!(AgentId::parse("claude-code"), Some(AgentId::Claude)); + assert_eq!(AgentId::parse("open-code"), Some(AgentId::OpenCode)); + assert_eq!(AgentId::parse("unknown"), None); + assert_eq!(AgentId::Claude.canonical_source(), "claude-code"); + assert!(AgentId::Codex.spec().capabilities.transcript_import); + assert!(!AgentId::Pi.spec().capabilities.transcript_import); + assert!(AgentId::OpenCode.spec().capabilities.data_tools); + } + + #[cfg(unix)] + #[test] + fn managed_run_exit_error_preserves_the_child_code() { + use std::os::unix::process::ExitStatusExt; + + let error = ManagedRunExitError::new(std::process::ExitStatus::from_raw(37 << 8)); + + assert_eq!(error.code(), Some(37)); + assert_eq!(error.status().code(), Some(37)); + assert!(error.to_string().contains("37")); } } diff --git a/bt-daemon/src/paths.rs b/bt-daemon/src/paths.rs index 58cb4b8..d41851c 100644 --- a/bt-daemon/src/paths.rs +++ b/bt-daemon/src/paths.rs @@ -1,6 +1,7 @@ //! Socket and data-directory resolution. Both `serve` and `hook` must agree on //! the defaults, so the logic lives here. See `docs/protocol.md`. +use crate::{AgentId, AgentSettingsLocation}; use std::path::{Path, PathBuf}; /// Env override for the socket path (also settable via `--socket`). @@ -89,17 +90,17 @@ pub fn agent_settings_path(source: &str, explicit: Option<&Path>) -> PathBuf { if let Some(path) = std::env::var_os(SETTINGS_ENV) { return PathBuf::from(path); } - match source { - "codex" => home().join(".codex").join("braintrust.json"), - "claude" | "claude-code" => home().join(".claude").join("braintrust.json"), - "opencode" => std::env::var_os("XDG_CONFIG_HOME") + match AgentId::parse(source).map(|agent| agent.spec().settings_location) { + Some(AgentSettingsLocation::Codex) => home().join(".codex").join("braintrust.json"), + Some(AgentSettingsLocation::Claude) => home().join(".claude").join("braintrust.json"), + Some(AgentSettingsLocation::OpenCode) => std::env::var_os("XDG_CONFIG_HOME") .filter(|path| !path.is_empty()) .map(PathBuf::from) .unwrap_or_else(|| home().join(".config")) .join("opencode") .join("braintrust.json"), - "pi" => home().join(".pi").join("agent").join("braintrust.json"), - other => data_dir(None).join("agents").join(format!("{other}.json")), + Some(AgentSettingsLocation::Pi) => home().join(".pi").join("agent").join("braintrust.json"), + None => data_dir(None).join("agents").join(format!("{source}.json")), } } @@ -131,6 +132,18 @@ mod tests { ); } + #[test] + fn aliases_share_their_canonical_settings_location() { + assert_eq!( + agent_settings_path("claude", None), + agent_settings_path("claude-code", None) + ); + assert_eq!( + agent_settings_path("open-code", None), + agent_settings_path("opencode", None) + ); + } + #[test] fn private_directory_is_created() { let temp = tempfile::tempdir().unwrap(); diff --git a/bt-daemon/src/server.rs b/bt-daemon/src/server.rs index b9e58b7..dff8d22 100644 --- a/bt-daemon/src/server.rs +++ b/bt-daemon/src/server.rs @@ -74,13 +74,15 @@ struct SessionAuthState { /// carried by its hook or import envelope. #[derive(Clone, Debug, PartialEq, Eq, Hash)] struct DeliveryKey { + source: String, session_id: String, route: String, } impl DeliveryKey { - fn new(session_id: &str, route: &SessionRoute) -> anyhow::Result { + fn new(source: &str, session_id: &str, route: &SessionRoute) -> anyhow::Result { Ok(Self { + source: source.to_string(), session_id: session_id.to_string(), route: serde_json::to_string(route)?, }) @@ -139,7 +141,7 @@ impl Daemon { "session route is missing its trace destination; select a project or destination during `bt trace setup` or `bt trace run`" ); } - let key = DeliveryKey::new(&env.session_id, &route)?; + let key = DeliveryKey::new(&env.source, &env.session_id, &route)?; let (selection, reason, expected_profile) = { let states = self.session_auth.lock().await; match states.get(&key) { @@ -257,7 +259,8 @@ impl Daemon { // cheap and allocation-free here no matter how long the recorded // session is. Bound it to what is recorded now, before this event is // appended, so replay covers recovery only. - let journal_path = journal::journal_path(&self.data_dir, &env.session_id); + let journal_path = + journal::ensure_source_journal(&self.data_dir, &env.source, &env.session_id).await?; let replay = ReplayPlan { through: journal::JournalReader::recorded_len(&journal_path).await, journal_path, @@ -285,7 +288,7 @@ impl Daemon { /// journal file for the rest of the daemon's life; deterministic span ids /// mean a late event simply rebuilds it from the journal. async fn retire_session(&self, key: &DeliveryKey) { - let lock = self.session_lock(&key.session_id); + let lock = self.session_lock(&key.source, &key.session_id); let _guard = lock.lock().await; let session = { self.sessions.lock().unwrap().remove(key) }; @@ -309,10 +312,11 @@ impl Daemon { .lock() .unwrap() .keys() - .any(|other| other.session_id == key.session_id); + .any(|other| other.source == key.source && other.session_id == key.session_id); if last { - self.journals.lock().unwrap().remove(&key.session_id); - self.session_locks.lock().unwrap().remove(&key.session_id); + let storage_key = crate::ids::session_namespace(&key.source, &key.session_id); + self.journals.lock().unwrap().remove(&storage_key); + self.session_locks.lock().unwrap().remove(&storage_key); } tracing::info!(session_id = %key.session_id, "session retired"); } @@ -334,17 +338,21 @@ impl Daemon { async fn append_to_journal(&self, env: &mut Envelope) -> anyhow::Result<()> { hydrate_transcript_reference(&self.data_dir, env).await; - let existing = { self.journals.lock().unwrap().get(&env.session_id).cloned() }; + let storage_key = crate::ids::session_namespace(&env.source, &env.session_id); + let existing = { self.journals.lock().unwrap().get(&storage_key).cloned() }; let writer = match existing { Some(writer) => writer, None => { + let path = + journal::ensure_source_journal(&self.data_dir, &env.source, &env.session_id) + .await?; let writer = Arc::new(tokio::sync::Mutex::new( - JournalWriter::open(&self.data_dir, &env.session_id).await?, + JournalWriter::open_path(&path).await?, )); self.journals .lock() .unwrap() - .entry(env.session_id.clone()) + .entry(storage_key) .or_insert_with(|| writer.clone()) .clone() } @@ -353,11 +361,12 @@ impl Daemon { result } - fn session_lock(&self, session_id: &str) -> Arc> { + fn session_lock(&self, source: &str, session_id: &str) -> Arc> { + let storage_key = crate::ids::session_namespace(source, session_id); self.session_locks .lock() .unwrap() - .entry(session_id.to_string()) + .entry(storage_key) .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) .clone() } @@ -390,6 +399,7 @@ impl Daemon { return; } let record = journal::ManagedRunKey { + source: Some(key.source.clone()), session_id: key.session_id.clone(), route: route.clone(), }; @@ -417,8 +427,46 @@ impl Daemon { // persisted record keeps flush accounting accurate for runs whose // events were accepted by an earlier daemon generation. for record in journal::read_managed_run_keys(&self.data_dir, ¶ms.managed_run_id).await { - if let Ok(key) = DeliveryKey::new(&record.session_id, &record.route) { - delivery_keys.insert(key); + if let Some(source) = record.source { + if let Ok(key) = DeliveryKey::new(&source, &record.session_id, &record.route) { + delivery_keys.insert(key); + } + } else { + // Legacy records predate source-qualified delivery keys. They + // can still identify any matching live delivery pipeline. + let live: Vec<_> = self + .sessions + .lock() + .unwrap() + .keys() + .filter(|key| { + key.session_id == record.session_id + && serde_json::from_str::(&key.route) + .is_ok_and(|route| route.same_route(&record.route)) + }) + .cloned() + .collect(); + if live.is_empty() { + if let Some(source) = journal::legacy_journal_source( + &self.data_dir, + &record.session_id, + &record.route, + ) + .await + .and_then(|source| { + self.translators + .canonical_source(&source) + .map(str::to_owned) + }) { + if let Ok(key) = + DeliveryKey::new(&source, &record.session_id, &record.route) + { + delivery_keys.insert(key); + } + } + } else { + delivery_keys.extend(live); + } } } let mut result = FlushResult { @@ -624,6 +672,12 @@ async fn serve_connection(daemon: Arc, stream: ServerStream) -> anyhow:: } async fn accept_event(daemon: &Arc, mut env: Envelope) -> Result<(), String> { + let canonical_source = daemon + .translators + .canonical_source(&env.source) + .ok_or_else(|| format!("unsupported coding-agent source {:?}", env.source))? + .to_string(); + env.source = canonical_source; let source = env.source.clone(); let event = env.event.clone(); let session_id = env.session_id.clone(); @@ -631,7 +685,7 @@ async fn accept_event(daemon: &Arc, mut env: Envelope) -> Result<(), Str let route = env.route.clone(); tracing::info!(source, event, session_id, "event received"); daemon.touch(); - let session_lock = daemon.session_lock(&session_id); + let session_lock = daemon.session_lock(&source, &session_id); let _session_guard = session_lock.lock().await; let result = async { diff --git a/bt-daemon/src/setup.rs b/bt-daemon/src/setup.rs index 4792f78..2528ae1 100644 --- a/bt-daemon/src/setup.rs +++ b/bt-daemon/src/setup.rs @@ -1,9 +1,9 @@ //! Persistent installation and configuration for coding-agent tracing plugins. use crate::paths; -use crate::trace_command::{EnableArgs, SetupAgent}; +use crate::trace_command::{DisableArgs, EnableArgs}; use crate::wire::SessionRoute; -use crate::TraceCommandOutput; +use crate::{AgentId, TraceCommandOutput}; use anyhow::{bail, Context}; use serde_json::{Map, Value}; use std::io::Write; @@ -12,12 +12,8 @@ use std::process::Command as ProcessCommand; const CODEX_MARKETPLACE: &str = "braintrust-codex-plugins"; const CODEX_MARKETPLACE_SOURCE: &str = "braintrustdata/braintrust-codex-plugin"; -const CODEX_PLUGIN: &str = "trace-codex@braintrust-codex-plugins"; const CLAUDE_MARKETPLACE: &str = "braintrust-claude-plugin"; const CLAUDE_MARKETPLACE_SOURCE: &str = "braintrustdata/braintrust-claude-plugin"; -const CLAUDE_PLUGIN: &str = "trace-claude-code@braintrust-claude-plugin"; -const OPENCODE_PLUGIN: &str = "@braintrust/trace-opencode@^1"; -const PI_PLUGIN: &str = "npm:@braintrust/pi-extension@^1"; trait CommandRunner { fn json(&mut self, program: &str, args: &[&str]) -> anyhow::Result; @@ -106,23 +102,10 @@ fn setup_codex(runner: &mut impl CommandRunner) -> anyhow::Result<()> { // Adding is idempotent and reconciles the installed cache to the refreshed // marketplace snapshot. - runner.run("codex", &["plugin", "add", CODEX_PLUGIN]) -} - -fn codex_plugin(value: &Value) -> Option<&Value> { - value - .get("installed") - .and_then(Value::as_array)? - .iter() - .find(|item| item.get("pluginId").and_then(Value::as_str) == Some(CODEX_PLUGIN)) -} - -fn disable_codex(runner: &mut impl CommandRunner) -> anyhow::Result<()> { - let plugins = runner.json("codex", &["plugin", "list", "--json"])?; - if codex_plugin(&plugins).is_some() { - runner.run("codex", &["plugin", "remove", CODEX_PLUGIN, "--json"])?; - } - Ok(()) + runner.run( + "codex", + &["plugin", "add", AgentId::Codex.spec().setup_package], + ) } fn claude_marketplace(value: &Value) -> Option<&Value> { @@ -141,10 +124,9 @@ fn claude_marketplace_is_published(item: &Value) -> bool { } fn claude_plugin(value: &Value) -> Option<&Value> { - value - .as_array()? - .iter() - .find(|item| item.get("id").and_then(Value::as_str) == Some(CLAUDE_PLUGIN)) + value.as_array()?.iter().find(|item| { + item.get("id").and_then(Value::as_str) == Some(AgentId::Claude.spec().setup_package) + }) } fn setup_claude(runner: &mut impl CommandRunner) -> anyhow::Result<()> { @@ -180,30 +162,34 @@ fn setup_claude(runner: &mut impl CommandRunner) -> anyhow::Result<()> { // Claude removes a marketplace's installed plugins when that marketplace // is removed, so replacing a stale source requires a fresh installation. if marketplace_replaced { - return runner.run("claude", &["plugin", "install", CLAUDE_PLUGIN]); + return runner.run( + "claude", + &["plugin", "install", AgentId::Claude.spec().setup_package], + ); } let plugins = runner.json("claude", &["plugin", "list", "--json"])?; match claude_plugin(&plugins) { - None => runner.run("claude", &["plugin", "install", CLAUDE_PLUGIN]), + None => runner.run( + "claude", + &["plugin", "install", AgentId::Claude.spec().setup_package], + ), Some(plugin) => { - runner.run("claude", &["plugin", "update", CLAUDE_PLUGIN])?; + runner.run( + "claude", + &["plugin", "update", AgentId::Claude.spec().setup_package], + )?; if plugin.get("enabled").and_then(Value::as_bool) == Some(false) { - runner.run("claude", &["plugin", "enable", CLAUDE_PLUGIN])?; + runner.run( + "claude", + &["plugin", "enable", AgentId::Claude.spec().setup_package], + )?; } Ok(()) } } } -fn disable_claude(runner: &mut impl CommandRunner) -> anyhow::Result<()> { - let plugins = runner.json("claude", &["plugin", "list", "--json"])?; - if claude_plugin(&plugins).is_some() { - runner.run("claude", &["plugin", "uninstall", CLAUDE_PLUGIN])?; - } - Ok(()) -} - fn load_object(path: &Path) -> anyhow::Result> { match std::fs::read(path) { Ok(raw) => { @@ -247,28 +233,46 @@ fn write_object_atomic(path: &Path, object: Map) -> anyhow::Resul Ok(()) } -fn setup_opencode_at(path: &Path) -> anyhow::Result<()> { +fn reconcile_opencode_at(path: &Path, install: bool) -> anyhow::Result<()> { + if !install && !path.exists() { + return Ok(()); + } let mut config = load_object(path)?; - let plugins = config - .entry("plugin") - .or_insert_with(|| Value::Array(Vec::new())) - .as_array_mut() - .ok_or_else(|| { + if let Some(plugins) = config.get_mut("plugin") { + let plugins = plugins.as_array_mut().ok_or_else(|| { anyhow::anyhow!( "OpenCode `plugin` config must be an array: {}", path.display() ) })?; - plugins.retain(|plugin| { - plugin.as_str().is_none_or(|plugin| { - plugin != "@braintrust/trace-opencode" - && !plugin.starts_with("@braintrust/trace-opencode@") - }) - }); - plugins.push(Value::String(OPENCODE_PLUGIN.into())); + plugins.retain(|plugin| { + plugin.as_str().is_none_or(|plugin| { + plugin != "@braintrust/trace-opencode" + && !plugin.starts_with("@braintrust/trace-opencode@") + && !plugin.starts_with("@braintrust/trace-opencode/") + }) + }); + if install { + plugins.push(Value::String(AgentId::OpenCode.spec().setup_package.into())); + } + if plugins.is_empty() { + config.remove("plugin"); + } + } else if install { + config.insert( + "plugin".into(), + Value::Array(vec![Value::String( + AgentId::OpenCode.spec().setup_package.into(), + )]), + ); + } write_object_atomic(path, config) } +fn setup_opencode_at(path: &Path) -> anyhow::Result<()> { + reconcile_opencode_at(path, true) +} + fn setup_opencode() -> anyhow::Result<()> { let settings_path = paths::agent_settings_path("opencode", None); let path = settings_path @@ -278,58 +282,54 @@ fn setup_opencode() -> anyhow::Result<()> { setup_opencode_at(&path) } -fn remove_opencode_plugin_at(path: &Path) -> anyhow::Result<()> { - let mut config = match std::fs::read(path) { - Ok(raw) => serde_json::from_slice::(&raw) - .with_context(|| format!("invalid JSON configuration: {}", path.display()))? - .as_object() - .cloned() - .ok_or_else(|| { - anyhow::anyhow!("configuration must be a JSON object: {}", path.display()) - })?, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), - Err(error) => { - return Err(error) - .with_context(|| format!("failed to read configuration: {}", path.display())) - } - }; - let Some(plugins) = config.get_mut("plugin") else { - return Ok(()); - }; - let plugins = plugins.as_array_mut().ok_or_else(|| { - anyhow::anyhow!( - "OpenCode `plugin` config must be an array: {}", - path.display() - ) - })?; - let original_len = plugins.len(); - plugins.retain(|plugin| { - plugin.as_str().is_none_or(|plugin| { - plugin != "@braintrust/trace-opencode" - && !plugin.starts_with("@braintrust/trace-opencode@") +fn setup_pi(runner: &mut impl CommandRunner) -> anyhow::Result<()> { + runner.run("pi", &["install", AgentId::Pi.spec().setup_package]) +} + +fn codex_plugin(value: &Value) -> Option<&Value> { + value + .get("installed") + .and_then(Value::as_array)? + .iter() + .find(|item| { + item.get("pluginId").and_then(Value::as_str) + == Some(AgentId::Codex.spec().uninstall_package) }) - }); - if plugins.len() != original_len { - write_object_atomic(path, config)?; - } - Ok(()) } -fn disable_opencode() -> anyhow::Result<()> { - let settings_path = paths::agent_settings_path("opencode", None); - let path = settings_path - .parent() - .unwrap_or_else(|| Path::new(".")) - .join("opencode.json"); - remove_opencode_plugin_at(&path) +fn uninstall_codex(runner: &mut impl CommandRunner) -> anyhow::Result<()> { + let plugins = runner.json("codex", &["plugin", "list", "--json"])?; + if codex_plugin(&plugins).is_some() { + runner.run( + "codex", + &[ + "plugin", + "remove", + AgentId::Codex.spec().uninstall_package, + "--json", + ], + )?; + } + Ok(()) } -fn setup_pi(runner: &mut impl CommandRunner) -> anyhow::Result<()> { - runner.run("pi", &["install", PI_PLUGIN]) +fn uninstall_claude(runner: &mut impl CommandRunner) -> anyhow::Result<()> { + let plugins = runner.json("claude", &["plugin", "list", "--json"])?; + if claude_plugin(&plugins).is_some() { + runner.run( + "claude", + &[ + "plugin", + "uninstall", + AgentId::Claude.spec().uninstall_package, + ], + )?; + } + Ok(()) } -fn disable_pi(runner: &mut impl CommandRunner) -> anyhow::Result<()> { - runner.run("pi", &["uninstall", PI_PLUGIN]) +fn uninstall_pi(runner: &mut impl CommandRunner) -> anyhow::Result<()> { + runner.run("pi", &["uninstall", AgentId::Pi.spec().setup_package]) } fn enable_tracing_at(path: &Path, mut route: SessionRoute) -> anyhow::Result<()> { @@ -362,71 +362,50 @@ fn enable_tracing(source: &str, route: SessionRoute) -> anyhow::Result Ok(path) } -fn remove_tracing_settings(path: &Path) -> anyhow::Result<()> { - match std::fs::remove_file(path) { - Ok(()) => Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(error) - .with_context(|| format!("failed to remove tracing settings: {}", path.display())), +fn uninstall_tracing_at(path: &Path) -> anyhow::Result<()> { + if !path.exists() { + return Ok(()); + } + let mut settings = load_object(path)?; + settings.remove("trace_to_braintrust"); + settings.remove("traceToBraintrust"); + settings.remove("route"); + settings.remove("project"); + if settings.is_empty() { + std::fs::remove_file(path) + .with_context(|| format!("failed to remove agent settings: {}", path.display()))?; + Ok(()) + } else { + write_object_atomic(path, settings) } } -/// Uninstall an agent's tracing adapter and remove its Braintrust-owned settings. -pub fn run_disable(agent: SetupAgent) -> anyhow::Result { - let mut runner = SystemCommandRunner; - let (source, display_name) = agent_details(agent); - match agent { - SetupAgent::Codex => disable_codex(&mut runner)?, - SetupAgent::Claude => disable_claude(&mut runner)?, - SetupAgent::OpenCode => disable_opencode()?, - SetupAgent::Pi => disable_pi(&mut runner)?, - } - let settings_path = paths::agent_settings_path(source, None); - remove_tracing_settings(&settings_path)?; - Ok(TraceCommandOutput::disable( - source, - display_name, - settings_path, - )) -} - -fn agent_details(agent: SetupAgent) -> (&'static str, &'static str) { - match agent { - SetupAgent::Codex => ("codex", "Codex"), - SetupAgent::Claude => ("claude", "Claude Code"), - SetupAgent::OpenCode => ("opencode", "OpenCode"), - SetupAgent::Pi => ("pi", "Pi"), - } +fn settings_path(agent: AgentId) -> PathBuf { + paths::agent_settings_path(agent.canonical_source(), None) } /// Install or refresh one agent's published tracing adapter and persist its /// non-secret route selection. pub fn run_enable(args: EnableArgs, route: SessionRoute) -> anyhow::Result { let mut runner = SystemCommandRunner; - let (source, display_name) = match args.agent { - SetupAgent::Codex => { + let agent = args.agent; + match agent { + AgentId::Codex => { setup_codex(&mut runner)?; - ("codex", "Codex") } - SetupAgent::Claude => { + AgentId::Claude => { setup_claude(&mut runner)?; - ("claude", "Claude Code") } - SetupAgent::OpenCode => { + AgentId::OpenCode => { setup_opencode()?; - ("opencode", "OpenCode") } - SetupAgent::Pi => { + AgentId::Pi => { setup_pi(&mut runner)?; - ("pi", "Pi") } - }; - let settings_path = enable_tracing(source, route)?; - Ok(TraceCommandOutput::setup( - source, - display_name, - settings_path, - )) + } + let spec = agent.spec(); + let settings_path = enable_tracing(spec.canonical_source, route)?; + Ok(TraceCommandOutput::setup(spec, settings_path)) } /// Backwards-compatible library entry point for hosts that used the former setup name. @@ -434,6 +413,29 @@ pub fn run_setup(args: EnableArgs, route: SessionRoute) -> anyhow::Result anyhow::Result { + let mut runner = SystemCommandRunner; + let agent = args.agent; + match agent { + AgentId::Codex => uninstall_codex(&mut runner)?, + AgentId::Claude => uninstall_claude(&mut runner)?, + AgentId::OpenCode => { + let settings_path = paths::agent_settings_path("opencode", None); + let opencode_path = settings_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join("opencode.json"); + reconcile_opencode_at(&opencode_path, false)?; + } + AgentId::Pi => uninstall_pi(&mut runner)?, + } + let spec = agent.spec(); + let settings_path = settings_path(agent); + uninstall_tracing_at(&settings_path)?; + Ok(TraceCommandOutput::disable(spec, settings_path)) +} + #[cfg(test)] mod tests { use super::*; @@ -472,6 +474,18 @@ mod tests { } } + #[test] + fn missing_agent_executable_has_an_actionable_error() { + let mut runner = SystemCommandRunner; + let error = runner + .run("bt-test-agent-that-does-not-exist", &[]) + .unwrap_err(); + + assert!(error + .to_string() + .contains("install bt-test-agent-that-does-not-exist and ensure it is on PATH")); + } + #[test] fn codex_installs_from_the_published_marketplace_when_missing() { let mut runner = FakeRunner::new([serde_json::json!({"marketplaces": []})]); @@ -542,7 +556,7 @@ mod tests { "repo": CLAUDE_MARKETPLACE_SOURCE }]), serde_json::json!([{ - "id": CLAUDE_PLUGIN, + "id": AgentId::Claude.spec().setup_package, "version": "1.4.4", "enabled": true }]), @@ -580,7 +594,7 @@ mod tests { "source": "github", "repo": CLAUDE_MARKETPLACE_SOURCE }]), - serde_json::json!([{"id": CLAUDE_PLUGIN, "enabled": false}]), + serde_json::json!([{"id": AgentId::Claude.spec().setup_package, "enabled": false}]), ]); setup_claude(&mut runner).unwrap(); @@ -614,12 +628,47 @@ mod tests { setup_opencode_at(&path).unwrap(); - let config: Value = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); + let config: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); assert_eq!(config["model"], "test/model"); assert_eq!( config["plugin"], serde_json::json!(["other", "@braintrust/trace-opencode@^1"]) ); + + setup_opencode_at(&path).unwrap(); + let config: Value = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); + assert_eq!( + config["plugin"], + serde_json::json!(["other", "@braintrust/trace-opencode@^1"]) + ); + } + + #[test] + fn opencode_uninstall_removes_only_braintrust_plugins() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("opencode.json"); + std::fs::write( + &path, + r#"{"plugin":["other","@braintrust/trace-opencode@0.9.0","@braintrust/trace-opencode/tracing"],"model":"test/model"}"#, + ) + .unwrap(); + + reconcile_opencode_at(&path, false).unwrap(); + + let config: Value = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); + assert_eq!(config["plugin"], serde_json::json!(["other"])); + assert_eq!(config["model"], "test/model"); + } + + #[test] + fn opencode_uninstall_is_idempotent_and_does_not_create_config() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("opencode.json"); + + reconcile_opencode_at(&path, false).unwrap(); + reconcile_opencode_at(&path, false).unwrap(); + + assert!(!path.exists()); } #[test] @@ -631,6 +680,15 @@ mod tests { assert!(runner.called("pi install npm:@braintrust/pi-extension@^1")); } + #[test] + fn pi_disable_uninstalls_the_published_extension_range() { + let mut runner = FakeRunner::new([]); + + uninstall_pi(&mut runner).unwrap(); + + assert!(runner.called("pi uninstall npm:@braintrust/pi-extension@^1")); + } + #[test] fn tracing_settings_preserve_unrelated_fields_and_remove_legacy_keys() { let temp = tempfile::tempdir().unwrap(); @@ -693,50 +751,56 @@ mod tests { } #[test] - fn disabling_removes_only_the_braintrust_settings_file() { + fn disable_removes_only_braintrust_owned_settings() { let temp = tempfile::tempdir().unwrap(); let path = temp.path().join("braintrust.json"); std::fs::write( &path, - r#"{"traceToBraintrust":true,"route":{"destination":{"project_name":"coding-agents"}},"other":true}"#, + r#"{"trace_to_braintrust":false,"traceToBraintrust":true,"route":{"destination":{"type":"project_logs","project_name":"agents"}},"project":"legacy","unrelated":{"keep":true}}"#, ) .unwrap(); - remove_tracing_settings(&path).unwrap(); - assert!(!path.exists()); + uninstall_tracing_at(&path).unwrap(); + + let settings: Value = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); + assert_eq!(settings, serde_json::json!({"unrelated": {"keep": true}})); } #[test] - fn disabling_installed_plugins_uses_each_agents_uninstall_command() { - let mut codex = FakeRunner::new([serde_json::json!({ - "installed": [{"pluginId": CODEX_PLUGIN}] - })]); - disable_codex(&mut codex).unwrap(); - assert!(codex.called("codex plugin remove trace-codex@braintrust-codex-plugins --json")); + fn disable_removes_an_owned_only_file_and_is_idempotent() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("braintrust.json"); + std::fs::write(&path, r#"{"trace_to_braintrust":true,"route":{}}"#).unwrap(); - let mut claude = FakeRunner::new([serde_json::json!([{"id": CLAUDE_PLUGIN}])]); - disable_claude(&mut claude).unwrap(); - assert!(claude.called("claude plugin uninstall trace-claude-code@braintrust-claude-plugin")); + uninstall_tracing_at(&path).unwrap(); + uninstall_tracing_at(&path).unwrap(); - let mut pi = FakeRunner::new([]); - disable_pi(&mut pi).unwrap(); - assert!(pi.called("pi uninstall npm:@braintrust/pi-extension@^1")); + assert!(!path.exists()); } #[test] - fn disabling_opencode_removes_only_the_managed_plugin() { - let temp = tempfile::tempdir().unwrap(); - let path = temp.path().join("opencode.json"); - std::fs::write( - &path, - r#"{"plugin":["other","@braintrust/trace-opencode@^1"],"model":"test/model"}"#, - ) - .unwrap(); + fn plugin_uninstall_skips_absent_codex_and_claude_plugins() { + let mut codex = FakeRunner::new([serde_json::json!({"installed": []})]); + uninstall_codex(&mut codex).unwrap(); + assert_eq!(codex.calls, ["codex plugin list --json"]); + + let mut claude = FakeRunner::new([serde_json::json!([])]); + uninstall_claude(&mut claude).unwrap(); + assert_eq!(claude.calls, ["claude plugin list --json"]); + } - remove_opencode_plugin_at(&path).unwrap(); + #[test] + fn plugin_uninstall_removes_exact_braintrust_plugins() { + let mut codex = FakeRunner::new([serde_json::json!({ + "installed": [{"pluginId": AgentId::Codex.spec().uninstall_package}] + })]); + uninstall_codex(&mut codex).unwrap(); + assert!(codex.called("codex plugin remove trace-codex@braintrust-codex-plugins --json")); - let config: Value = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); - assert_eq!(config["plugin"], serde_json::json!(["other"])); - assert_eq!(config["model"], "test/model"); + let mut claude = FakeRunner::new([serde_json::json!([{ + "id": AgentId::Claude.spec().uninstall_package + }])]); + uninstall_claude(&mut claude).unwrap(); + assert!(claude.called("claude plugin uninstall trace-claude-code@braintrust-claude-plugin")); } } diff --git a/bt-daemon/src/trace_command.rs b/bt-daemon/src/trace_command.rs index 235ed44..9cb3abf 100644 --- a/bt-daemon/src/trace_command.rs +++ b/bt-daemon/src/trace_command.rs @@ -4,7 +4,7 @@ //! integrations they control. Hosts such as `bt` provide global auth flags and //! dispatch these commands without duplicating agent-specific CLI knowledge. -use crate::{HookArgs, ImportArgs, RunArgs, ServeArgs, StatusArgs}; +use crate::{AgentId, HookArgs, ImportArgs, RunArgs, ServeArgs, StatusArgs}; use clap::{Args, Subcommand}; use std::path::PathBuf; @@ -51,7 +51,8 @@ pub struct StopArgs { #[derive(Debug, Clone, Args)] pub struct EnableArgs { - #[command(subcommand)] + /// Coding agent to configure. + #[arg(value_enum)] pub agent: SetupAgent, /// JSON object persisted in this agent's tracing route and merged into root-span metadata. #[arg(long, global = true, env = "BRAINTRUST_ADDITIONAL_METADATA")] @@ -63,22 +64,13 @@ pub type SetupArgs = EnableArgs; #[derive(Debug, Clone, Args)] pub struct DisableArgs { - #[command(subcommand)] - pub agent: SetupAgent, + /// Coding agent whose Braintrust tracing integration should be removed. + #[arg(value_enum)] + pub agent: AgentId, } -#[derive(Debug, Clone, Copy, Subcommand)] -pub enum SetupAgent { - /// Install the published Codex tracing plugin. - Codex, - /// Install the published Claude Code tracing plugin. - Claude, - /// Configure the published OpenCode tracing plugin. - #[command(name = "opencode", alias = "open-code")] - OpenCode, - /// Install the published Pi tracing extension. - Pi, -} +/// Backwards-compatible name for the shared coding-agent identity. +pub type SetupAgent = AgentId; #[cfg(test)] mod tests { @@ -109,15 +101,6 @@ mod tests { }) if value == r#"{"setup":true}"# )); - let legacy_setup = Cli::try_parse_from(["bt", "setup", "codex"]).unwrap(); - assert!(matches!( - legacy_setup.trace.command, - TraceCommand::Setup(SetupArgs { - agent: SetupAgent::Codex, - .. - }) - )); - let hook = Cli::try_parse_from([ "bt", "hook", @@ -168,4 +151,47 @@ mod tests { }) if value == r#"{"import":true}"# )); } + + #[test] + fn lifecycle_commands_share_agent_aliases() { + let disable = Cli::try_parse_from(["bt", "disable", "claude-code"]).unwrap(); + assert!(matches!( + disable.trace.command, + TraceCommand::Disable(DisableArgs { + agent: AgentId::Claude + }) + )); + + let enable = Cli::try_parse_from(["bt", "enable", "open-code"]).unwrap(); + assert!(matches!( + enable.trace.command, + TraceCommand::Setup(EnableArgs { + agent: AgentId::OpenCode, + .. + }) + )); + } + + #[test] + fn hook_accepts_adapter_provenance() { + let hook = Cli::try_parse_from([ + "bt", + "hook", + "--source", + "codex", + "--source-version", + "1.2.3", + "--plugin-version", + "4.5.6", + ]) + .unwrap(); + assert!(matches!( + hook.trace.command, + TraceCommand::Hook(HookArgs { + source_version: Some(ref source_version), + plugin_version: Some(ref plugin_version), + .. + }) if source_version == "1.2.3" && plugin_version == "4.5.6" + )); + } } diff --git a/bt-daemon/src/trace_runtime.rs b/bt-daemon/src/trace_runtime.rs index 39a8731..28c6dbe 100644 --- a/bt-daemon/src/trace_runtime.rs +++ b/bt-daemon/src/trace_runtime.rs @@ -10,8 +10,8 @@ use crate::wire::{AuthSelection, SessionConfig, SessionRoute}; use crate::{ apply_additional_metadata, braintrust_serve_options, paths, run_disable, run_enable, run_hook, run_import, run_serve, run_status, run_traced, shutdown_daemon, AuthLease, AuthProvider, - AuthResolveReason, BraintrustSinkConfig, HostInfo, OutputFormat, Registry, RunHookCommand, - ServeOptions, StatusArgs, TraceArgs, TraceCommandOutput, + AuthResolveReason, BraintrustSinkConfig, HostInfo, ManagedRunExitError, OutputFormat, Registry, + RunHookCommand, ServeOptions, StatusArgs, TraceArgs, TraceCommandOutput, }; use async_trait::async_trait; use std::ffi::OsString; @@ -201,7 +201,7 @@ pub async fn run_trace(args: TraceArgs, host: TraceHostContext) -> anyhow::Resul print_output(run_enable(enable_args, route)?, host.output_format) } TraceCommand::Disable(disable_args) => { - print_output(run_disable(disable_args.agent)?, host.output_format) + print_output(run_disable(disable_args)?, host.output_format) } TraceCommand::Daemon(serve_args) => { init_daemon_logging(host.verbose); @@ -266,7 +266,7 @@ pub async fn run_trace(args: TraceArgs, host: TraceHostContext) -> anyhow::Resul if status.success() { Ok(()) } else { - anyhow::bail!("coding agent exited with {status}") + Err(ManagedRunExitError::new(status).into()) } } } @@ -515,6 +515,7 @@ mod tests { let args = crate::HookArgs { source: "codex".into(), source_version: None, + plugin_version: None, socket: None, session_id_field: "session_id".into(), event_field: "hook_event_name".into(), diff --git a/bt-daemon/src/transcript_import/codex.rs b/bt-daemon/src/transcript_import/codex.rs index 09f5895..98a1a87 100644 --- a/bt-daemon/src/transcript_import/codex.rs +++ b/bt-daemon/src/transcript_import/codex.rs @@ -65,6 +65,10 @@ impl Tail { } pub(super) fn transcript_session_id(path: &Path) -> Option { + transcript_session_id_with_subagents(path, false) +} + +fn transcript_session_id_with_subagents(path: &Path, include_subagents: bool) -> Option { let file = std::fs::File::open(path).ok()?; for line in std::io::BufReader::new(file).lines().map_while(Result::ok) { let Ok(record) = serde_json::from_str::(&line) else { @@ -73,7 +77,7 @@ pub(super) fn transcript_session_id(path: &Path) -> Option { if record.get("type").and_then(Value::as_str) != Some("session_meta") { continue; } - if record.pointer("/payload/source/subagent").is_some() { + if !include_subagents && record.pointer("/payload/source/subagent").is_some() { return None; } let session_id = record.pointer("/payload/id").and_then(Value::as_str)?; @@ -207,7 +211,7 @@ fn append_subagent_events( if !visited.insert(call.agent_id.clone()) { continue; } - let Some(child_path) = find_transcript_by_id(&search_root, &call.agent_id) else { + let Some(child_path) = find_transcript_by_id(&search_root, &call.agent_id)? else { continue; }; let child_records = read_jsonl_records(&child_path)?; @@ -353,13 +357,28 @@ fn transcript_search_root(path: &Path) -> PathBuf { .unwrap_or_else(|| PathBuf::from(".")) } -fn find_transcript_by_id(root: &Path, session_id: &str) -> Option { +fn find_transcript_by_id(root: &Path, session_id: &str) -> anyhow::Result> { let mut candidates = Vec::new(); find_jsonl_files(root, &mut candidates); candidates.sort(); - candidates + let matches = candidates .into_iter() - .find(|path| filename_matches(path, session_id)) + .filter(|path| { + transcript_session_id_with_subagents(path, true).as_deref() == Some(session_id) + }) + .collect::>(); + match matches.as_slice() { + [] => Ok(None), + [path] => Ok(Some(path.clone())), + paths => { + let locations = paths + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join(", "); + bail!("multiple Codex transcripts found for subagent {session_id}: {locations}") + } + } } fn last_message(records: &[Value]) -> Option { diff --git a/bt-daemon/src/transcript_import/mod.rs b/bt-daemon/src/transcript_import/mod.rs index d546fee..2a022f9 100644 --- a/bt-daemon/src/transcript_import/mod.rs +++ b/bt-daemon/src/transcript_import/mod.rs @@ -3,7 +3,7 @@ use crate::ImportSource; use anyhow::{bail, Context}; use serde_json::Value; use std::collections::BTreeMap; -use std::io::BufRead; +use std::io::{BufRead, Read, Seek}; use std::path::{Path, PathBuf}; mod claude; @@ -136,10 +136,11 @@ fn resolve_transcript_in( for root in roots { let mut candidates = Vec::new(); find_jsonl_files(root, &mut candidates); - matches.extend(candidates.into_iter().filter(|path| match source { - ImportSource::Codex => codex::filename_matches(path, session_id), - ImportSource::Claude => claude::filename_matches(path, session_id), - })); + matches.extend( + candidates + .into_iter() + .filter(|path| transcript_session_id(path, source).as_deref() == Some(session_id)), + ); } matches.sort(); matches.dedup(); @@ -188,38 +189,148 @@ fn source_name(source: ImportSource) -> &'static str { } } +#[cfg(test)] pub(crate) fn transcript_envelopes( path: &Path, source: ImportSource, ) -> anyhow::Result> { - let file = - std::fs::File::open(path).with_context(|| format!("read transcript {}", path.display()))?; - let mut reader = std::io::BufReader::new(file); - let mut records = Vec::new(); - let mut record_end_offsets = Vec::new(); - let mut offset = 0_u64; - let mut line = String::new(); - let mut index = 0_usize; - while reader.read_line(&mut line)? != 0 { - index += 1; - offset += line.len() as u64; - if !line.trim().is_empty() { - records.push( - serde_json::from_str(&line).with_context(|| { - format!("parse transcript {} line {}", path.display(), index) - })?, - ); - record_end_offsets.push(offset); - } - line.clear(); + let mut records = IncrementalRecords::default(); + records.refresh(path, true)?; + envelopes_from_records(path, source, &records) +} + +fn envelopes_from_records( + path: &Path, + source: ImportSource, + records: &IncrementalRecords, +) -> anyhow::Result> { + match source { + ImportSource::Codex => codex::envelopes(path, &records.values), + ImportSource::Claude => claude::envelopes( + path, + &records.values, + &records.end_offsets, + records.read_offset, + ), } - if records.is_empty() { - bail!("transcript {} is empty", path.display()); +} + +/// Append-only JSONL reader used by attach. Native transcript files can be +/// large, so unchanged history is parsed once and retained as structured +/// records instead of being read and decoded again on every poll. +#[derive(Default)] +struct IncrementalRecords { + values: Vec, + end_offsets: Vec, + read_offset: u64, + line_count: usize, + modified: Option, + anchor: Vec, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Refresh { + Unchanged, + Appended, + Reset, +} + +impl IncrementalRecords { + fn refresh(&mut self, path: &Path, finalize: bool) -> anyhow::Result { + let metadata = std::fs::metadata(path) + .with_context(|| format!("read transcript metadata {}", path.display()))?; + let len = metadata.len(); + let modified = metadata.modified().ok(); + let prefix_changed = self.read_offset > 0 + && len >= self.read_offset + && read_anchor(path, self.read_offset, self.anchor.len())? != self.anchor; + let reset = self.read_offset > 0 + && (len < self.read_offset + || prefix_changed + || (len == self.read_offset && modified != self.modified)); + if reset { + *self = Self::default(); + } + if len == self.read_offset { + self.modified = modified; + if self.values.is_empty() && finalize { + bail!("transcript {} is empty", path.display()); + } + return Ok(if reset { + Refresh::Reset + } else { + Refresh::Unchanged + }); + } + + let start_offset = self.read_offset; + let mut file = std::fs::File::open(path) + .with_context(|| format!("read transcript {}", path.display()))?; + file.seek(std::io::SeekFrom::Start(self.read_offset))?; + let mut reader = std::io::BufReader::new(file); + let mut values = Vec::new(); + let mut end_offsets = Vec::new(); + let mut offset = self.read_offset; + let mut parsed_through = self.read_offset; + let mut line_count = self.line_count; + let mut parsed_line_count = self.line_count; + let mut line = String::new(); + loop { + let bytes = reader.read_line(&mut line)?; + if bytes == 0 { + break; + } + line_count += 1; + offset += bytes as u64; + if !line.trim().is_empty() { + match serde_json::from_str(&line) { + Ok(value) => { + values.push(value); + end_offsets.push(offset); + } + Err(_) if !finalize && offset == len && !line.ends_with('\n') => break, + Err(error) => { + return Err(error).with_context(|| { + format!("parse transcript {} line {}", path.display(), line_count) + }); + } + } + } + parsed_through = offset; + parsed_line_count = line_count; + line.clear(); + } + + self.values.extend(values); + self.end_offsets.extend(end_offsets); + self.read_offset = parsed_through; + self.line_count = parsed_line_count; + self.modified = modified; + self.anchor = read_anchor(path, parsed_through, 64)?; + if self.values.is_empty() && finalize { + bail!("transcript {} is empty", path.display()); + } + Ok(if reset { + Refresh::Reset + } else if parsed_through > start_offset { + Refresh::Appended + } else { + Refresh::Unchanged + }) } - match source { - ImportSource::Codex => codex::envelopes(path, &records), - ImportSource::Claude => claude::envelopes(path, &records, &record_end_offsets, offset), +} + +fn read_anchor(path: &Path, through: u64, max_len: usize) -> anyhow::Result> { + let len = usize::try_from(through.min(max_len as u64)).unwrap_or(max_len); + if len == 0 { + return Ok(Vec::new()); } + let mut file = + std::fs::File::open(path).with_context(|| format!("read transcript {}", path.display()))?; + file.seek(std::io::SeekFrom::Start(through - len as u64))?; + let mut anchor = vec![0; len]; + file.read_exact(&mut anchor)?; + Ok(anchor) } /// Incrementally converts a growing native transcript into synthetic hook @@ -229,7 +340,7 @@ pub(crate) struct TranscriptTail { path: PathBuf, source: ImportSource, state: TailState, - observed_file: Option<(u64, Option)>, + records: IncrementalRecords, } enum TailState { @@ -242,11 +353,15 @@ impl TranscriptTail { Self { path, source, - state: match source { - ImportSource::Codex => TailState::Codex(codex::Tail::default()), - ImportSource::Claude => TailState::Claude(claude::Tail::default()), - }, - observed_file: None, + state: Self::new_state(source), + records: IncrementalRecords::default(), + } + } + + fn new_state(source: ImportSource) -> TailState { + match source { + ImportSource::Codex => TailState::Codex(codex::Tail::default()), + ImportSource::Claude => TailState::Claude(claude::Tail::default()), } } @@ -257,16 +372,18 @@ impl TranscriptTail { Err(error) => return Err(error.into()), }; let len = metadata.len(); - let observed_file = (len, metadata.modified().ok()); - if !finalize && self.observed_file == Some(observed_file) { - return Ok(Vec::new()); - } - let events = match transcript_envelopes(&self.path, self.source) { - Ok(events) => events, + let refresh = match self.records.refresh(&self.path, finalize) { + Ok(refresh) => refresh, Err(_) if !finalize => return Ok(Vec::new()), Err(error) => return Err(error), }; - self.observed_file = Some(observed_file); + if refresh == Refresh::Unchanged && !finalize { + return Ok(Vec::new()); + } + if refresh == Refresh::Reset { + self.state = Self::new_state(self.source); + } + let events = envelopes_from_records(&self.path, self.source, &self.records)?; match &mut self.state { TailState::Codex(state) => state.poll(events, len, finalize), TailState::Claude(state) => state.poll(events, len, finalize), @@ -351,7 +468,11 @@ mod tests { .join("2026/07/31") .join("rollout-2026-07-31T12-00-00-session-123.jsonl"); std::fs::create_dir_all(transcript.parent().unwrap()).unwrap(); - std::fs::write(&transcript, "{}\n").unwrap(); + std::fs::write( + &transcript, + r#"{"type":"session_meta","payload":{"id":"session-123"}}"#, + ) + .unwrap(); assert_eq!( resolve_transcript_in("session-123", ImportSource::Codex, &[root]).unwrap(), @@ -365,9 +486,13 @@ mod tests { let root = temp.path().join("projects"); let project = root.join("-tmp-project"); std::fs::create_dir_all(&project).unwrap(); - std::fs::write(project.join("prefix-session-123.jsonl"), "{}\n").unwrap(); + std::fs::write( + project.join("prefix-session-123.jsonl"), + r#"{"type":"user","sessionId":"prefix-session-123"}"#, + ) + .unwrap(); let transcript = project.join("session-123.jsonl"); - std::fs::write(&transcript, "{}\n").unwrap(); + std::fs::write(&transcript, r#"{"type":"user","sessionId":"session-123"}"#).unwrap(); assert_eq!( resolve_transcript_in("session-123", ImportSource::Claude, &[root]).unwrap(), @@ -402,8 +527,16 @@ mod tests { .unwrap_err(); assert!(missing.to_string().contains("no Claude Code transcript")); - std::fs::write(first.join("duplicate.jsonl"), "{}\n").unwrap(); - std::fs::write(second.join("duplicate.jsonl"), "{}\n").unwrap(); + std::fs::write( + first.join("duplicate.jsonl"), + r#"{"type":"user","sessionId":"duplicate"}"#, + ) + .unwrap(); + std::fs::write( + second.join("duplicate.jsonl"), + r#"{"type":"user","sessionId":"duplicate"}"#, + ) + .unwrap(); let ambiguous = resolve_transcript_in("duplicate", ImportSource::Claude, &[first, second]).unwrap_err(); assert!(ambiguous @@ -411,6 +544,22 @@ mod tests { .contains("multiple Claude Code transcripts")); } + #[test] + fn explicit_lookup_rejects_filename_only_session_matches() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("sessions"); + let transcript = root.join("rollout-session-123.jsonl"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write( + transcript, + r#"{"type":"session_meta","payload":{"id":"different-session"}}"#, + ) + .unwrap(); + + let error = resolve_transcript_in("session-123", ImportSource::Codex, &[root]).unwrap_err(); + assert!(error.to_string().contains("no Codex transcript")); + } + #[test] fn discovers_only_top_level_codex_transcripts_in_stable_order() { let temp = tempfile::tempdir().unwrap(); @@ -562,6 +711,31 @@ mod tests { })); } + #[test] + fn codex_import_rejects_ambiguous_subagent_transcripts() { + let temp = tempfile::tempdir().unwrap(); + let parent = temp.path().join("rollout-parent.jsonl"); + let records = vec![ + json!({"timestamp":"2026-01-01T00:00:01Z","type":"session_meta","payload":{"id":"parent"}}), + json!({"timestamp":"2026-01-01T00:00:02Z","type":"response_item","payload":{"type":"function_call","call_id":"call-a","name":"spawn_agent","arguments":"{}"}}), + json!({"timestamp":"2026-01-01T00:00:03Z","type":"response_item","payload":{"type":"function_call_output","call_id":"call-a","output":"{\"agent_id\":\"child-a\"}"}}), + ]; + for directory in ["first", "second"] { + let path = temp.path().join(directory).join("rollout-child-a.jsonl"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write( + path, + json!({"timestamp":"2026-01-01T00:00:02Z","type":"session_meta","payload":{"id":"child-a","source":{"subagent":{}}}}).to_string(), + ) + .unwrap(); + } + + let error = codex::envelopes(&parent, &records).unwrap_err(); + assert!(error + .to_string() + .contains("multiple Codex transcripts found for subagent child-a")); + } + #[test] fn codex_import_adds_native_turn_checkpoints() { let records = vec![ @@ -619,6 +793,42 @@ mod tests { assert_eq!(tail.poll(true).unwrap().last().unwrap().event, "Stop"); } + #[test] + fn incremental_reader_handles_partial_appends_and_truncation() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("session.jsonl"); + std::fs::write(&path, "{\"type\":\"session_meta\"").unwrap(); + let mut records = IncrementalRecords::default(); + assert!(records.refresh(&path, false).unwrap() == Refresh::Unchanged); + assert!(records.values.is_empty()); + + std::fs::write( + &path, + "{\"type\":\"session_meta\",\"payload\":{\"id\":\"session\"}}\n", + ) + .unwrap(); + assert!(records.refresh(&path, false).unwrap() == Refresh::Appended); + assert_eq!(records.values.len(), 1); + + std::fs::write(&path, "{\"type\":\"event_msg\"}\n").unwrap(); + assert!(records.refresh(&path, false).unwrap() == Refresh::Reset); + assert_eq!(records.values, vec![json!({"type":"event_msg"})]); + + std::fs::write( + &path, + "{\"type\":\"replacement_with_a_longer_prefix\"}\n{\"type\":\"second\"}\n", + ) + .unwrap(); + assert!(records.refresh(&path, false).unwrap() == Refresh::Reset); + assert_eq!( + records.values, + vec![ + json!({"type":"replacement_with_a_longer_prefix"}), + json!({"type":"second"}) + ] + ); + } + #[test] fn claude_tail_closes_only_completed_turns() { let temp = tempfile::tempdir().unwrap(); diff --git a/bt-daemon/src/translate/claude.rs b/bt-daemon/src/translate/claude.rs index 9e5bf63..890ca2e 100644 --- a/bt-daemon/src/translate/claude.rs +++ b/bt-daemon/src/translate/claude.rs @@ -8,7 +8,9 @@ use super::git::GitMetadataCache; use super::recent::RecentSet; -use super::{AgentTranslator, SessionCtx, SpanOp, SpanRow, SpanType, TranslatorFactory}; +use super::{ + root_metadata, AgentTranslator, SessionCtx, SpanOp, SpanRow, SpanType, TranslatorFactory, +}; use crate::ids; use crate::wire::Envelope; use serde_json::{json, Map, Value}; @@ -97,6 +99,7 @@ struct ClaudeTranslator { git: Arc, current_cwd: Option, last_turn_cwd: Option, + last_ts_ms: i64, } impl ClaudeTranslator { @@ -126,6 +129,7 @@ impl ClaudeTranslator { git, current_cwd: None, last_turn_cwd: None, + last_ts_ms: 0, } } @@ -144,17 +148,14 @@ impl ClaudeTranslator { } let cwd = string_field(&event.payload, "cwd").unwrap_or_default(); let workspace = basename(&cwd); - let mut metadata = ctx - .config - .as_ref() - .and_then(|c| c.additional_metadata.clone()) - .and_then(|v| v.as_object().cloned()) - .unwrap_or_default(); - // Internal routing settings must never appear as user metadata. - metadata.retain(|key, _| !key.starts_with("_bt_")); - metadata.insert("session_id".into(), json!(self.session_id)); + let mut metadata = root_metadata( + ctx.config + .as_ref() + .and_then(|config| config.additional_metadata.as_ref()), + "claude-code", + &ctx.session_id, + ); metadata.insert("workspace".into(), json!(cwd)); - metadata.insert("source".into(), json!("claude-code")); metadata.insert("hostname".into(), json!(hostname())); metadata.insert("username".into(), json!(username())); metadata.insert( @@ -705,6 +706,7 @@ impl AgentTranslator for ClaudeTranslator { "Claude translator has pending catch-up work; drain it before handling another event" ); let mut ops = Vec::new(); + self.last_ts_ms = self.last_ts_ms.max(event.ts_ms); if let Some(cwd) = string_field(&event.payload, "cwd") { self.current_cwd = Some(cwd); } @@ -778,8 +780,47 @@ impl AgentTranslator for ClaudeTranslator { Ok(Some(ops)) } - fn flush(&mut self, _ctx: &SessionCtx) -> anyhow::Result> { - Ok(Vec::new()) + fn finalize(&mut self, _ctx: &SessionCtx) -> anyhow::Result> { + let end_ms = self.last_ts_ms; + let mut ops = Vec::new(); + for (_, tool) in self.pending_tools.drain() { + ops.push(SpanOp::Merge(SpanRow { + span_id: tool.span_id, + root_span_id: self.root_span_id.clone(), + end_ms: Some(end_ms), + error: Some("Session ended before tool completion".into()), + ..Default::default() + })); + } + if let Some(turn) = self.turn.take() { + ops.push(SpanOp::Merge(SpanRow { + span_id: turn.id, + root_span_id: self.root_span_id.clone(), + end_ms: Some(end_ms), + error: Some("Session ended before turn completion".into()), + ..Default::default() + })); + } + for (_, subagent) in self.subagents.drain() { + ops.push(SpanOp::Merge(SpanRow { + span_id: subagent.span_id, + root_span_id: self.root_span_id.clone(), + end_ms: Some(end_ms), + error: Some("Session ended before subagent completion".into()), + ..Default::default() + })); + } + if self.root_open && !self.root_ended { + self.root_ended = true; + ops.push(SpanOp::Merge(SpanRow { + span_id: self.session_span_id.clone(), + root_span_id: self.root_span_id.clone(), + end_ms: Some(end_ms), + ..Default::default() + })); + } + self.release_terminal_state(); + Ok(ops) } } diff --git a/bt-daemon/src/translate/codex.rs b/bt-daemon/src/translate/codex.rs index de53562..a7ccf7c 100644 --- a/bt-daemon/src/translate/codex.rs +++ b/bt-daemon/src/translate/codex.rs @@ -13,13 +13,15 @@ //! synthetic llm span showing the before/after context. //! //! Turn-terminal transcript *polling* (TS waits up to 10s for a late -//! `task_complete`) is replaced by re-reading on the next event and on -//! `flush()`. Native turn ids keep those late records correlated even when a +//! `task_complete`) is replaced by re-reading on the next event and at +//! checkpoints. Native turn ids keep those late records correlated even when a //! newer turn has already started. use super::git::GitMetadataCache; use super::recent::{RecentMap, RecentSet}; -use super::{AgentTranslator, SessionCtx, SpanOp, SpanRow, SpanType, TranslatorFactory}; +use super::{ + root_metadata, AgentTranslator, SessionCtx, SpanOp, SpanRow, SpanType, TranslatorFactory, +}; use crate::ids; use crate::wire::Envelope; use regex::Regex; @@ -52,6 +54,7 @@ impl TranslatorFactory for CodexTranslatorFactory { Box::new(CodexTranslator { session_id: session_id.to_string(), root_span_id: ids::span_id(session_id, "root"), + effective_root_span_id: ids::span_id(session_id, "root"), external_parent_span_id: None, root_opened: false, root_ended: false, @@ -99,6 +102,8 @@ struct OpenLlm { struct Scope { path: String, + read_path: String, + through_offset: Option, kind: ScopeKind, offset: u64, /// Parent span id for this scope's turn spans (main root, or subagent root). @@ -145,15 +150,17 @@ enum PendingWork { through_ms: Option, after: DeferredHook, }, - Flush { + CatchUp { paths: Vec, next_path: usize, + finalize: bool, }, } struct CodexTranslator { session_id: String, root_span_id: String, + effective_root_span_id: String, external_parent_span_id: Option, root_opened: bool, root_ended: bool, @@ -182,7 +189,12 @@ impl AgentTranslator for CodexTranslator { let mut ops = Vec::new(); if let Some(config) = &ctx.config { - self.external_parent_span_id = config.attached_span_ids().0; + let (parent_span_id, root_span_id) = config.attached_span_ids(); + self.external_parent_span_id = parent_span_id; + if !self.root_opened { + self.effective_root_span_id = + root_span_id.unwrap_or_else(|| self.root_span_id.clone()); + } self.project = config.project_name().map(ToOwned::to_owned); self.additional_metadata = config .additional_metadata @@ -217,6 +229,9 @@ impl AgentTranslator for CodexTranslator { self.main_path.get_or_insert(path.clone()); self.ensure_main_scope(&path); } + if let Some(scope) = self.scopes.get_mut(&path) { + scope.observe_transcript(payload, &path); + } let import_through_ms = payload.get("_bt_import_through_ms").and_then(Value::as_i64); let after = self.deferred_hook(event, agent_id.is_none()); if self.catch_up_chunk(&path, event.ts_ms, import_through_ms, &mut ops) { @@ -259,24 +274,30 @@ impl AgentTranslator for CodexTranslator { }); } } - PendingWork::Flush { + PendingWork::CatchUp { paths, mut next_path, + finalize, } => { while next_path < paths.len() { let path = &paths[next_path]; if self.catch_up_chunk(path, 0, None, &mut ops) { - if let Some(mut scope) = self.scopes.remove(path) { - self.close_dangling(&mut scope, None, &mut ops); - self.scopes.insert(path.clone(), scope); + if finalize { + if let Some(mut scope) = self.scopes.remove(path) { + self.close_dangling(&mut scope, None, &mut ops); + self.scopes.insert(path.clone(), scope); + } } next_path += 1; } // Return after any completed scope or a bounded partial read. - // This keeps a flush over many scopes bounded as well. + // This keeps catch-up over many scopes bounded as well. if !ops.is_empty() || next_path < paths.len() { - self.pending = (next_path < paths.len()) - .then_some(PendingWork::Flush { paths, next_path }); + self.pending = (next_path < paths.len()).then_some(PendingWork::CatchUp { + paths, + next_path, + finalize, + }); break; } } @@ -285,25 +306,35 @@ impl AgentTranslator for CodexTranslator { Ok(Some(ops)) } - fn flush(&mut self, ctx: &SessionCtx) -> anyhow::Result> { + fn checkpoint(&mut self, ctx: &SessionCtx) -> anyhow::Result> { + self.start_catch_up(ctx, false) + } + + fn finalize(&mut self, ctx: &SessionCtx) -> anyhow::Result> { + self.start_catch_up(ctx, true) + } +} + +impl CodexTranslator { + fn start_catch_up(&mut self, ctx: &SessionCtx, finalize: bool) -> anyhow::Result> { anyhow::ensure!( self.pending.is_none(), - "Codex translator has pending catch-up work; drain it before flushing" + "Codex translator has pending catch-up work; drain it before checkpointing" ); - // Re-read each scope to catch a late task_complete, then close dangling. + // Re-read each scope to catch a late task_complete. Finalization also + // closes dangling work whose terminal native event never arrived. let paths: Vec = self.scopes.keys().cloned().collect(); if paths.is_empty() { return Ok(Vec::new()); } - self.pending = Some(PendingWork::Flush { + self.pending = Some(PendingWork::CatchUp { paths, next_path: 0, + finalize, }); Ok(self.drain_pending(ctx)?.unwrap_or_default()) } -} -impl CodexTranslator { fn ensure_main_scope(&mut self, path: &str) { if self.scopes.contains_key(path) { return; @@ -328,7 +359,7 @@ impl CodexTranslator { let span_id = ids::span_id(&self.session_id, &format!("turn:{turn_id}")); ops.push(SpanOp::Merge(SpanRow { span_id, - root_span_id: self.root_span_id.clone(), + root_span_id: self.effective_root_span_id.clone(), metadata: Some(json!({ "compaction": { "trigger": trigger } })), ..Default::default() })); @@ -438,9 +469,10 @@ impl CodexTranslator { return true; }; let read = read_new_lines( - &scope.path, + &scope.read_path, &mut scope.offset, through_ms, + scope.through_offset, CATCH_UP_BYTE_BUDGET, ); for line in read.lines { @@ -486,7 +518,7 @@ impl CodexTranslator { }; ops.push(SpanOp::Merge(SpanRow { span_id: scope.turn_parent_span_id.clone(), - root_span_id: self.root_span_id.clone(), + root_span_id: self.effective_root_span_id.clone(), input: Some(input), metadata: Some(json!({ "model": m })), ..Default::default() @@ -538,7 +570,12 @@ impl CodexTranslator { }; let cwd = str_field(payload, "cwd"); self.root_cwd = cwd.clone(); - let mut md = self.additional_metadata.clone(); + let additional = Value::Object(self.additional_metadata.clone()); + let mut md = root_metadata( + Some(&additional), + "codex", + ids::native_session_id(&self.session_id), + ); for k in ["id", "cwd", "cli_version"] { if let Some(v) = str_field(payload, k) { md.insert( @@ -552,7 +589,7 @@ impl CodexTranslator { } } if let Some(s) = &self.source { - md.insert("source".into(), json!(s)); + md.insert("session_source".into(), json!(s)); } if let Some(pm) = &self.permission_mode { md.insert("permission_mode".into(), json!(pm)); @@ -569,7 +606,7 @@ impl CodexTranslator { scope.root_created = true; ops.push(SpanOp::Insert(SpanRow { span_id: self.root_span_id.clone(), - root_span_id: self.root_span_id.clone(), + root_span_id: self.effective_root_span_id.clone(), parent_span_ids: self.external_parent_span_id.clone().into_iter().collect(), name, span_type: SpanType::Task, @@ -595,7 +632,7 @@ impl CodexTranslator { .unwrap_or_else(|| self.root_span_id.clone()); ops.push(SpanOp::Insert(SpanRow { span_id: scope.turn_parent_span_id.clone(), - root_span_id: self.root_span_id.clone(), + root_span_id: self.effective_root_span_id.clone(), parent_span_ids: vec![parent], name: format!("subagent: {agent_id}"), span_type: SpanType::Task, @@ -622,7 +659,7 @@ impl CodexTranslator { let span_id = ids::span_id(&self.session_id, &format!("turn:{turn_id}")); ops.push(SpanOp::Insert(SpanRow { span_id: span_id.clone(), - root_span_id: self.root_span_id.clone(), + root_span_id: self.effective_root_span_id.clone(), parent_span_ids: vec![scope.turn_parent_span_id.clone()], name: format!("turn: {turn_id}"), span_type: SpanType::Task, @@ -655,7 +692,7 @@ impl CodexTranslator { } ops.push(SpanOp::Merge(SpanRow { span_id: turn.span_id.clone(), - root_span_id: self.root_span_id.clone(), + root_span_id: self.effective_root_span_id.clone(), input: Some(json!(text)), metadata: explicit_skill_metadata(&turn.explicit_skill_names), ..Default::default() @@ -693,7 +730,7 @@ impl CodexTranslator { let turn_id = turn.turn_id.clone(); ops.push(SpanOp::Insert(SpanRow { span_id: span_id.clone(), - root_span_id: self.root_span_id.clone(), + root_span_id: self.effective_root_span_id.clone(), parent_span_ids: vec![turn_span], name, span_type: SpanType::Llm, @@ -736,7 +773,7 @@ impl CodexTranslator { if let Some(metadata) = explicit_skill_metadata(&turn.explicit_skill_names) { ops.push(SpanOp::Merge(SpanRow { span_id: turn.span_id.clone(), - root_span_id: self.root_span_id.clone(), + root_span_id: self.effective_root_span_id.clone(), metadata: Some(metadata), ..Default::default() })); @@ -869,7 +906,7 @@ impl CodexTranslator { ops.push(SpanOp::Insert(SpanRow { span_id: span_id.clone(), - root_span_id: self.root_span_id.clone(), + root_span_id: self.effective_root_span_id.clone(), parent_span_ids: vec![turn_span], name, span_type: SpanType::Tool, @@ -911,7 +948,7 @@ impl CodexTranslator { let error = output.as_ref().and_then(classify_tool_output); ops.push(SpanOp::Merge(SpanRow { span_id, - root_span_id: self.root_span_id.clone(), + root_span_id: self.effective_root_span_id.clone(), end_ms: Some(ts), output, metadata: Some(json!({ "tool_approval": "approved" })), @@ -961,7 +998,7 @@ impl CodexTranslator { }; ops.push(SpanOp::Merge(SpanRow { span_id: llm.span_id, - root_span_id: self.root_span_id.clone(), + root_span_id: self.effective_root_span_id.clone(), end_ms: Some(end), output, metadata: usage_metadata, @@ -1001,7 +1038,7 @@ impl CodexTranslator { }; ops.push(SpanOp::Merge(SpanRow { span_id: llm.span_id, - root_span_id: self.root_span_id.clone(), + root_span_id: self.effective_root_span_id.clone(), end_ms: Some(llm.last_output_ms), output, metadata: Some(json!({ @@ -1018,7 +1055,7 @@ impl CodexTranslator { .map(|s| json!(s)); ops.push(SpanOp::Merge(SpanRow { span_id: turn.span_id, - root_span_id: self.root_span_id.clone(), + root_span_id: self.effective_root_span_id.clone(), end_ms: Some(ts), output, ..Default::default() @@ -1042,7 +1079,7 @@ impl CodexTranslator { if let Some((span_id, _)) = scope.open_tools.remove(&call_id) { ops.push(SpanOp::Merge(SpanRow { span_id, - root_span_id: self.root_span_id.clone(), + root_span_id: self.effective_root_span_id.clone(), end_ms, metadata: Some(json!({ "tool_approval": "approved" })), error: Some(MISSING_TOOL_OUTPUT_ERROR.to_string()), @@ -1090,7 +1127,7 @@ impl CodexTranslator { // Relabel the turn as a compaction span. ops.push(SpanOp::Merge(SpanRow { span_id: turn_span.clone(), - root_span_id: self.root_span_id.clone(), + root_span_id: self.effective_root_span_id.clone(), name: "compaction".to_string(), span_type: SpanType::Task, metadata: Some(json!({ "compaction": { @@ -1112,7 +1149,7 @@ impl CodexTranslator { .unwrap_or_else(|| "compaction".to_string()); ops.push(SpanOp::Insert(SpanRow { span_id: span_id.clone(), - root_span_id: self.root_span_id.clone(), + root_span_id: self.effective_root_span_id.clone(), parent_span_ids: vec![turn_span.clone()], name: name.clone(), span_type: SpanType::Llm, @@ -1152,7 +1189,7 @@ impl CodexTranslator { .unwrap_or(fallback_ts); ops.push(SpanOp::Merge(SpanRow { span_id: self.root_span_id.clone(), - root_span_id: self.root_span_id.clone(), + root_span_id: self.effective_root_span_id.clone(), end_ms: Some(end_ms), ..Default::default() })); @@ -1181,7 +1218,7 @@ impl CodexTranslator { // End the subagent root span. ops.push(SpanOp::Merge(SpanRow { span_id: scope.turn_parent_span_id.clone(), - root_span_id: self.root_span_id.clone(), + root_span_id: self.effective_root_span_id.clone(), end_ms: Some(end), ..Default::default() })); @@ -1191,7 +1228,7 @@ impl CodexTranslator { // closed scope would only pin its entire conversation history. } - /// Close any open llm/tool/turn in `scope` (used on subagent stop + flush). + /// Close any open llm/tool/turn in `scope` (subagent stop or finalization). fn close_dangling(&mut self, scope: &mut Scope, end: Option, ops: &mut Vec) { let end_ms = end.or(scope.last_turn_end_ms); if let Some(llm) = scope.open_llm.take() { @@ -1202,7 +1239,7 @@ impl CodexTranslator { }; ops.push(SpanOp::Merge(SpanRow { span_id: llm.span_id, - root_span_id: self.root_span_id.clone(), + root_span_id: self.effective_root_span_id.clone(), end_ms: end_ms.or(Some(llm.last_output_ms)), output, metadata: Some(json!({ @@ -1219,7 +1256,7 @@ impl CodexTranslator { for (sid, error) in tools { ops.push(SpanOp::Merge(SpanRow { span_id: sid, - root_span_id: self.root_span_id.clone(), + root_span_id: self.effective_root_span_id.clone(), end_ms, metadata: Some(json!({ "tool_approval": "approved" })), error: Some(error), @@ -1229,7 +1266,7 @@ impl CodexTranslator { for turn in scope.open_turns.drain(..) { ops.push(SpanOp::Merge(SpanRow { span_id: turn.span_id, - root_span_id: self.root_span_id.clone(), + root_span_id: self.effective_root_span_id.clone(), end_ms, ..Default::default() })); @@ -1241,6 +1278,8 @@ impl Scope { fn new(path: &str, kind: ScopeKind, turn_parent_span_id: String) -> Self { Scope { path: path.to_string(), + read_path: path.to_string(), + through_offset: None, kind, offset: 0, turn_parent_span_id, @@ -1259,6 +1298,24 @@ impl Scope { subagent_ended: false, } } + + fn observe_transcript(&mut self, payload: &Value, original_path: &str) { + let mirror = payload + .get("_bt_transcript_mirror") + .filter(|mirror| mirror.get("path").and_then(Value::as_str) == Some(original_path)); + if let Some(mirror_path) = mirror + .and_then(|mirror| mirror.get("mirror")) + .and_then(Value::as_str) + { + self.read_path = mirror_path.to_string(); + self.through_offset = mirror + .and_then(|mirror| mirror.get("through")) + .and_then(Value::as_u64); + } else { + self.read_path = original_path.to_string(); + self.through_offset = None; + } + } } // ---- helpers --------------------------------------------------------------- @@ -1608,6 +1665,7 @@ fn read_new_lines( path: &str, offset: &mut u64, through_ms: Option, + through_offset: Option, byte_budget: usize, ) -> ReadLines { use std::io::{BufRead, BufReader, Seek, SeekFrom}; @@ -1632,6 +1690,9 @@ fn read_new_lines( let mut lines = Vec::new(); let mut consumed = 0usize; loop { + if through_offset.is_some_and(|through| *offset >= through) { + break; + } if consumed >= byte_budget && !lines.is_empty() { return ReadLines { lines, @@ -1642,7 +1703,10 @@ fn read_new_lines( let Ok(bytes) = reader.read_line(&mut line) else { break; }; - if bytes == 0 || !line.ends_with('\n') { + if bytes == 0 + || !line.ends_with('\n') + || through_offset.is_some_and(|through| *offset + bytes as u64 > through) + { break; } let trimmed = line.trim_end_matches(['\r', '\n']); diff --git a/bt-daemon/src/translate/debug.rs b/bt-daemon/src/translate/debug.rs index 2bbaf5c..199d428 100644 --- a/bt-daemon/src/translate/debug.rs +++ b/bt-daemon/src/translate/debug.rs @@ -74,7 +74,7 @@ impl AgentTranslator for DebugTranslator { Ok(ops) } - fn flush(&mut self, _ctx: &SessionCtx) -> anyhow::Result> { + fn finalize(&mut self, _ctx: &SessionCtx) -> anyhow::Result> { Ok(Vec::new()) } } diff --git a/bt-daemon/src/translate/mod.rs b/bt-daemon/src/translate/mod.rs index fc5d2ee..5cf2095 100644 --- a/bt-daemon/src/translate/mod.rs +++ b/bt-daemon/src/translate/mod.rs @@ -23,6 +23,7 @@ pub use pi::PiTranslatorFactory; use crate::wire::{Envelope, SessionConfig}; use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; use std::collections::HashMap; use std::sync::Arc; @@ -84,24 +85,59 @@ pub struct SessionCtx { pub config: Option, } +/// Apply user-supplied metadata while protecting daemon-owned identity fields. +/// All agent roots use this so routing internals and canonical identity cannot +/// drift between translators. +pub(crate) fn root_metadata( + additional: Option<&Value>, + source: &str, + session_id: &str, +) -> Map { + let mut metadata = additional + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + metadata.retain(|key, _| !key.starts_with("_bt_")); + metadata.insert("session_id".into(), Value::String(session_id.to_string())); + metadata.insert("source".into(), Value::String(source.to_string())); + metadata +} + /// A per-session state machine. One instance per session; `&mut self` so it /// can hold open-span maps, transcript offsets, etc. pub trait AgentTranslator: Send { /// Handle one event, returning span ops to emit. fn handle(&mut self, event: &Envelope, ctx: &SessionCtx) -> anyhow::Result>; - /// Continue bounded work started by [`Self::handle`] or [`Self::flush`]. + /// Continue bounded work started by [`Self::handle`], [`Self::checkpoint`], + /// or [`Self::finalize`]. /// `Some` means the caller must emit this batch and call again; `None` /// means the translator is fully caught up. fn drain_pending(&mut self, _ctx: &SessionCtx) -> anyhow::Result>> { Ok(None) } - /// Emit any pending spans (e.g. close dangling turns) at flush/shutdown. - fn flush(&mut self, ctx: &SessionCtx) -> anyhow::Result> { + /// Catch up any externally buffered observations without ending the + /// logical agent session. Delivery barriers call this before flushing the + /// sink, so a session can keep accepting later turns. + fn checkpoint(&mut self, ctx: &SessionCtx) -> anyhow::Result> { + let _ = ctx; + Ok(Vec::new()) + } + + /// Finish the logical agent session and defensively close any work whose + /// terminal native event never arrived. Called only when the session actor + /// itself is shutting down or being retired. + fn finalize(&mut self, ctx: &SessionCtx) -> anyhow::Result> { let _ = ctx; Ok(Vec::new()) } + + /// Backward-compatible terminal flush used by transcript import callers. + /// Live delivery barriers use [`Self::checkpoint`] instead. + fn flush(&mut self, ctx: &SessionCtx) -> anyhow::Result> { + self.finalize(ctx) + } } /// Builds translator instances for a given `source`. @@ -110,28 +146,33 @@ pub trait TranslatorFactory: Send + Sync { fn create(&self, session_id: &str) -> Box; } -/// Maps a `source` string to its factory, with a fallback for unknown sources. +/// Maps canonical and supported alias source strings to translator factories. +/// Production rejects unknown sources; the debug registry maps every known +/// agent identity to the pass-through translator for pipeline tests. pub struct Registry { factories: HashMap>, - fallback: Box, + debug_known_agents: bool, } impl Registry { - /// A registry whose only translator (and fallback) is the debug - /// pass-through. This is the Phase 1 default. + /// A pass-through registry for debug sinks and pipeline tests. Known agent + /// identities are accepted, while arbitrary unknown sources are rejected. pub fn debug_only() -> Self { let mut r = Registry { factories: HashMap::new(), - fallback: Box::new(DebugTranslatorFactory), + debug_known_agents: true, }; r.register(Box::new(DebugTranslatorFactory)); r } - /// The production registry: all real agent translators registered, debug - /// as the fallback for unknown sources. + /// The production registry with every real agent translator registered. pub fn default_agents() -> Self { - let mut r = Registry::debug_only(); + let mut r = Registry { + factories: HashMap::new(), + debug_known_agents: false, + }; + r.register(Box::new(DebugTranslatorFactory)); let git = Arc::new(git::GitMetadataCache::default()); r.register(Box::new(ClaudeTranslatorFactory::new(git.clone()))); r.register(Box::new(CodexTranslatorFactory::new(git.clone()))); @@ -151,15 +192,37 @@ impl Registry { v } - /// Create a translator for `source`, falling back (with a warning) to the - /// debug translator for an unknown source. - pub fn create(&self, source: &str, session_id: &str) -> Box { - match self.factories.get(source) { - Some(f) => f.create(session_id), - None => { - tracing::warn!(source, "no translator registered; using debug fallback"); - self.fallback.create(session_id) - } + pub fn canonical_source<'a>(&'a self, source: &'a str) -> Option<&'a str> { + if self.factories.contains_key(source) { + return Some(source); } + let canonical = crate::AgentId::parse(source)?.canonical_source(); + (self.factories.contains_key(canonical) || self.debug_known_agents).then_some(canonical) + } + + pub fn create_checked( + &self, + source: &str, + session_id: &str, + ) -> anyhow::Result> { + let canonical = self + .canonical_source(source) + .ok_or_else(|| anyhow::anyhow!("unsupported coding-agent source {source:?}"))?; + let factory = self.factories.get(canonical).or_else(|| { + self.debug_known_agents + .then(|| self.factories.get("debug")) + .flatten() + }); + let factory = factory.expect("canonical source must have a factory"); + let namespace = crate::ids::session_namespace(canonical, session_id); + Ok(factory.create(&namespace)) + } + + /// Create a known translator. Production ingress uses + /// [`Self::create_checked`] and returns an RPC error for unsupported + /// sources; this convenience remains for focused tests. + pub fn create(&self, source: &str, session_id: &str) -> Box { + self.create_checked(source, session_id) + .unwrap_or_else(|error| panic!("{error}")) } } diff --git a/bt-daemon/src/translate/opencode.rs b/bt-daemon/src/translate/opencode.rs index 69077a6..5b0b6e9 100644 --- a/bt-daemon/src/translate/opencode.rs +++ b/bt-daemon/src/translate/opencode.rs @@ -3,7 +3,9 @@ use super::git::GitMetadataCache; use super::recent::RecentSet; -use super::{AgentTranslator, SessionCtx, SpanOp, SpanRow, SpanType, TranslatorFactory}; +use super::{ + root_metadata, AgentTranslator, SessionCtx, SpanOp, SpanRow, SpanType, TranslatorFactory, +}; use crate::ids; use crate::wire::Envelope; use serde_json::{json, Value}; @@ -50,6 +52,7 @@ struct NativeSession { reasoning_parts: HashMap, tool_calls: HashMap>, tool_starts: HashMap, + tool_names: HashMap, tool_args: HashMap, tool_outputs: HashMap, tool_errors: HashMap, @@ -99,7 +102,7 @@ impl AgentTranslator for OpenCodeTranslator { Ok(ops) } - fn flush(&mut self, _ctx: &SessionCtx) -> anyhow::Result> { + fn finalize(&mut self, _ctx: &SessionCtx) -> anyhow::Result> { let now = self.last_ts_ms; let ids: Vec = self.sessions.keys().cloned().collect(); let mut ops = Vec::new(); @@ -172,15 +175,13 @@ impl OpenCodeTranslator { "OpenCode".to_string(), ) }; - let mut metadata = ctx - .config - .as_ref() - .and_then(|c| c.additional_metadata.as_ref()) - .and_then(Value::as_object) - .cloned() - .unwrap_or_default(); - metadata.insert("session_id".into(), Value::String(native_id.to_string())); - metadata.insert("source".into(), Value::String("opencode".into())); + let mut metadata = root_metadata( + ctx.config + .as_ref() + .and_then(|config| config.additional_metadata.as_ref()), + "opencode", + native_id, + ); if let Some(parent) = parent_id { metadata.insert("parent_session_id".into(), Value::String(parent.into())); metadata.insert("is_subagent".into(), Value::Bool(true)); @@ -450,6 +451,14 @@ impl OpenCodeTranslator { }; if let Some(s) = self.sessions.get_mut(&sid) { s.tool_starts.insert(call.into(), event.ts_ms); + if let Some(tool) = event + .payload + .pointer("/input/tool") + .or_else(|| event.payload.get("tool")) + .and_then(Value::as_str) + { + s.tool_names.insert(call.into(), tool.into()); + } if let Some(a) = event.payload.pointer("/output/args") { s.tool_args.insert(call.into(), a.clone()); } @@ -477,6 +486,7 @@ impl OpenCodeTranslator { return vec![]; }; if s.denied_tools.remove(call) { + s.tool_names.remove(call); return vec![]; } let Some(turn) = s.current_turn_span_id.clone() else { @@ -490,6 +500,7 @@ impl OpenCodeTranslator { .or_else(|| event.payload.get("output").cloned()); let error = s.tool_errors.remove(call); let args = s.tool_args.remove(call); + s.tool_names.remove(call); let name = if tool == "skill" { args.as_ref() .and_then(|v| v.get("name")) @@ -552,6 +563,7 @@ impl OpenCodeTranslator { return vec![]; }; s.denied_tools.insert(call.into()); + s.tool_names.remove(call); let tool = props.get("tool").and_then(Value::as_str).unwrap_or("tool"); return vec![SpanOp::Insert(SpanRow { span_id: ids::span_id(&self.daemon_session_id, &format!("tool:{sid}:{call}")), @@ -594,6 +606,32 @@ impl OpenCodeTranslator { return vec![]; }; let mut ops = vec![]; + let dangling_tools: Vec<(String, i64)> = s.tool_starts.drain().collect(); + for (call, start_ms) in dangling_tools { + if s.denied_tools.remove(&call) { + continue; + } + s.tool_call_count += 1; + let tool_name = s.tool_names.remove(&call).unwrap_or_else(|| "tool".into()); + ops.push(SpanOp::Insert(SpanRow { + span_id: ids::span_id(&self.daemon_session_id, &format!("tool:{sid}:{call}")), + root_span_id: s.effective_root_span_id.clone(), + parent_span_ids: s.current_turn_span_id.clone().into_iter().collect(), + name: tool_name.clone(), + span_type: SpanType::Tool, + start_ms: Some(start_ms), + end_ms: Some(ts), + input: s.tool_args.remove(&call), + metadata: Some(json!({ + "tool_name": tool_name, + "call_id": call, + "tool_approval": "approved", + "tool_outcome": "error", + })), + error: Some("Interrupted before tool completion".into()), + ..Default::default() + })); + } if let Some(turn) = s.current_turn_span_id.take() { ops.push(SpanOp::Merge(SpanRow { span_id: turn, diff --git a/bt-daemon/src/translate/pi.rs b/bt-daemon/src/translate/pi.rs index 46e5b1b..6edc78b 100644 --- a/bt-daemon/src/translate/pi.rs +++ b/bt-daemon/src/translate/pi.rs @@ -2,7 +2,9 @@ //! state machine owns all span construction and recovery. use super::git::GitMetadataCache; -use super::{AgentTranslator, SessionCtx, SpanOp, SpanRow, SpanType, TranslatorFactory}; +use super::{ + root_metadata, AgentTranslator, SessionCtx, SpanOp, SpanRow, SpanType, TranslatorFactory, +}; use crate::ids; use crate::wire::Envelope; use serde_json::{json, Value}; @@ -136,8 +138,10 @@ impl AgentTranslator for PiTranslator { Ok(ops) } - fn flush(&mut self, _ctx: &SessionCtx) -> anyhow::Result> { - let mut ops = self.close_turn(self.last_ts, Some("Interrupted before completion".into())); + fn finalize(&mut self, _ctx: &SessionCtx) -> anyhow::Result> { + let error = "Interrupted before completion"; + let mut ops = self.close_dangling(self.last_ts, error); + ops.extend(self.close_turn(self.last_ts, Some(error.into()))); if self.opened { ops.push(self.close_root(self.last_ts)); } @@ -146,6 +150,56 @@ impl AgentTranslator for PiTranslator { } impl PiTranslator { + fn close_dangling(&mut self, ts: i64, error: &str) -> Vec { + let Some((turn, _)) = &self.turn else { + self.pending_llms.clear(); + self.tools.clear(); + return Vec::new(); + }; + let mut ops = Vec::new(); + for pending in self.pending_llms.drain(..) { + self.llm_seq += 1; + ops.push(SpanOp::Insert(SpanRow { + span_id: ids::span_id( + &self.session_id, + &format!("llm:{}:{}", self.turn_seq, self.llm_seq), + ), + root_span_id: self.effective_root_span_id.clone(), + parent_span_ids: vec![turn.clone()], + name: "llm".into(), + span_type: SpanType::Llm, + start_ms: Some(pending.start_ms), + end_ms: Some(ts), + input: Some(pending.input), + metadata: pending.provider, + error: Some(error.into()), + ..Default::default() + })); + } + for (call, tool) in self.tools.drain() { + self.total_tools += 1; + ops.push(SpanOp::Insert(SpanRow { + span_id: ids::span_id(&self.session_id, &format!("tool:{}:{call}", self.turn_seq)), + root_span_id: self.effective_root_span_id.clone(), + parent_span_ids: vec![turn.clone()], + name: tool.name.clone(), + span_type: SpanType::Tool, + start_ms: Some(tool.start_ms), + end_ms: Some(ts), + input: Some(tool.args), + metadata: Some(json!({ + "tool_name": tool.name, + "tool_call_id": call, + "tool_approval": "approved", + "tool_outcome": "error", + })), + error: Some(error.into()), + ..Default::default() + })); + } + ops + } + fn ensure_root(&mut self, envelope: &Envelope, ctx: &SessionCtx) -> Vec { if self.opened { return Vec::new(); @@ -161,15 +215,13 @@ impl PiTranslator { .1 .or_else(|| self.external_parent.clone()) .unwrap_or_else(|| self.root_span_id.clone()); - let mut metadata = ctx - .config - .as_ref() - .and_then(|c| c.additional_metadata.as_ref()) - .and_then(Value::as_object) - .cloned() - .unwrap_or_default(); - metadata.insert("session_id".into(), json!(self.session_id)); - metadata.insert("source".into(), json!("pi")); + let mut metadata = root_metadata( + ctx.config + .as_ref() + .and_then(|config| config.additional_metadata.as_ref()), + "pi", + &ctx.session_id, + ); metadata.insert("pi_version".into(), json!(envelope.source_version)); metadata.insert( "extension_version".into(), diff --git a/bt-daemon/tests/codex_translator.rs b/bt-daemon/tests/codex_translator.rs index 955d574..11d6815 100644 --- a/bt-daemon/tests/codex_translator.rs +++ b/bt-daemon/tests/codex_translator.rs @@ -2,6 +2,7 @@ //! hook triggers into a session → turn → {llm, tool} span tree. Mirrors the //! happy-path shape of the TS `event-processor` tests. +use braintrust_sdk_rust::{SpanComponents, SpanObjectType}; use bt_daemon::wire::{BackendAuth, Envelope, FlushMode, SessionConfig}; use bt_daemon::{Registry, SessionCtx, SpanOp, SpanRow, SpanType}; use serde_json::{json, Value}; @@ -183,7 +184,8 @@ fn codex_happy_path_builds_session_turn_llm_tool_tree() { json!("gpt-5.5"), "model backfilled from turn_context" ); - assert_eq!(md["source"], json!("startup")); + assert_eq!(md["source"], json!("codex")); + assert_eq!(md["session_source"], json!("startup")); assert_eq!(md["permission_mode"], json!("auto")); // Turn. @@ -497,6 +499,97 @@ fn configured_ctx(session_id: &str, additional_metadata: Value) -> SessionCtx { } } +#[test] +fn codex_honors_external_parent_and_root_without_metadata_override() { + let tmp = tempfile::tempdir().unwrap(); + let transcript = tmp.path().join("rollout.jsonl"); + append( + &transcript, + json!({"timestamp":"2026-01-01T00:00:01Z","type":"session_meta","payload":{"id":"native","cwd":"/x/app"}}), + ); + let mut components = SpanComponents::new(SpanObjectType::ProjectLogs); + components.span_id = Some("external-parent".into()); + components.root_span_id = Some("external-root".into()); + let ctx = SessionCtx { + session_id: "daemon-session".into(), + config: Some(SessionConfig { + auth: BackendAuth { + token: "test".into(), + api_url: None, + app_url: None, + org_name: None, + org_id: None, + }, + destination: Some(bt_daemon::wire::TraceDestination::ParentSpan { components }), + flush_mode: FlushMode::FireAndForget, + additional_metadata: Some(json!({ + "source": "spoofed", + "session_id": "spoofed", + "_bt_secret": "hidden", + "team": "platform" + })), + }), + }; + let registry = Registry::default_agents(); + let mut translator = registry.create("codex", "daemon-session"); + let ops = translator + .handle( + &envelope( + "daemon-session", + "SessionStart", + transcript.to_str().unwrap(), + json!({"source":"startup"}), + ), + &ctx, + ) + .unwrap(); + let rows = reduce(ops); + let root = find(&rows, SpanType::Task, "codex: app"); + assert_eq!(root.root_span_id, "external-root"); + assert_eq!(root.parent_span_ids, vec!["external-parent"]); + let metadata = root.metadata.as_ref().unwrap(); + assert_eq!(metadata["source"], "codex"); + assert_eq!(metadata["session_id"], "native"); + assert_eq!(metadata["session_source"], "startup"); + assert_eq!(metadata["team"], "platform"); + assert!(metadata.get("_bt_secret").is_none()); +} + +#[test] +fn codex_replays_from_a_daemon_owned_mirror_after_source_deletion() { + let tmp = tempfile::tempdir().unwrap(); + let original = tmp.path().join("original.jsonl"); + let mirror = tmp.path().join("mirror.jsonl"); + let record = json!({"timestamp":"2026-01-01T00:00:01Z","type":"session_meta","payload":{"id":"native","cwd":"/x/app"}}); + append(&original, record.clone()); + append(&mirror, record); + let through = std::fs::metadata(&mirror).unwrap().len(); + std::fs::remove_file(&original).unwrap(); + + let registry = Registry::default_agents(); + let mut translator = registry.create("codex", "daemon-session"); + let ctx = SessionCtx { + session_id: "daemon-session".into(), + config: None, + }; + let mut hook = envelope( + "daemon-session", + "SessionStart", + original.to_str().unwrap(), + json!({}), + ); + hook.payload["_bt_transcript_mirror"] = json!({ + "path": original.to_str().unwrap(), + "mirror": mirror.to_str().unwrap(), + "through": through, + }); + let rows = reduce(translator.handle(&hook, &ctx).unwrap()); + assert_eq!( + find(&rows, SpanType::Task, "codex: app").root_span_id.len(), + 36 + ); +} + #[test] fn late_task_complete_is_correlated_by_turn_id() { let tmp = tempfile::tempdir().unwrap(); diff --git a/bt-daemon/tests/opencode_translator.rs b/bt-daemon/tests/opencode_translator.rs index 0d4bc69..ac1c4f0 100644 --- a/bt-daemon/tests/opencode_translator.rs +++ b/bt-daemon/tests/opencode_translator.rs @@ -185,3 +185,65 @@ fn opencode_additional_metadata_reaches_roots_without_overriding_session_fields( assert_eq!(root.metadata.as_ref().unwrap()["team"], "platform"); assert_eq!(root.metadata.as_ref().unwrap()["source"], "opencode"); } + +#[test] +fn opencode_checkpoint_preserves_session_state_for_later_turns() { + let registry = Registry::default_agents(); + let mut translator = registry.create("opencode", "root-session"); + let ctx = SessionCtx { + session_id: "root-session".into(), + config: None, + }; + translator + .handle( + &event( + "session.created", + 1, + json!({"properties":{"info":{"id":"native"}}}), + ), + &ctx, + ) + .unwrap(); + translator + .handle( + &event("chat.message", 2, json!({"input":{"sessionID":"native"}})), + &ctx, + ) + .unwrap(); + + assert!(translator.checkpoint(&ctx).unwrap().is_empty()); + let ops = translator + .handle( + &event("chat.message", 3, json!({"input":{"sessionID":"native"}})), + &ctx, + ) + .unwrap(); + assert!(ops + .iter() + .any(|op| matches!(op, SpanOp::Insert(row) if row.name == "Turn 2"))); + assert!(ops + .iter() + .all(|op| !matches!(op, SpanOp::Insert(row) if row.name == "OpenCode"))); +} + +#[test] +fn opencode_finalization_closes_a_missing_tool_completion() { + let registry = Registry::default_agents(); + let mut translator = registry.create("opencode", "root-session"); + let ctx = SessionCtx { + session_id: "root-session".into(), + config: None, + }; + for envelope in [ + event("chat.message", 1, json!({"input":{"sessionID":"native"}})), + event( + "tool.execute.before", + 2, + json!({"input":{"sessionID":"native","callID":"call","tool":"read"},"output":{"args":{"path":"x"}}}), + ), + ] { + translator.handle(&envelope, &ctx).unwrap(); + } + let ops = translator.finalize(&ctx).unwrap(); + assert!(ops.iter().any(|op| matches!(op, SpanOp::Insert(row) if row.span_type == SpanType::Tool && row.error.is_some()))); +} diff --git a/bt-daemon/tests/pi_translator.rs b/bt-daemon/tests/pi_translator.rs index 1d089f8..9956a13 100644 --- a/bt-daemon/tests/pi_translator.rs +++ b/bt-daemon/tests/pi_translator.rs @@ -159,3 +159,55 @@ fn pi_additional_metadata_reaches_roots_without_overriding_session_fields() { assert!(root.parent_span_ids.is_empty()); assert_eq!(root.root_span_id, root.span_id); } + +#[test] +fn pi_checkpoint_preserves_the_open_session_and_turn() { + let registry = Registry::default_agents(); + let mut translator = registry.create("pi", "pi-session"); + let ctx = SessionCtx { + session_id: "pi-session".into(), + config: None, + }; + translator + .handle(&event("session_start", 1, json!({"reason":"new"})), &ctx) + .unwrap(); + translator + .handle( + &event("before_agent_start", 2, json!({"prompt":"first"})), + &ctx, + ) + .unwrap(); + + assert!(translator.checkpoint(&ctx).unwrap().is_empty()); + let ops = translator + .handle(&event("agent_end", 3, json!({"messages":[]})), &ctx) + .unwrap(); + assert!(ops.iter().any( + |op| matches!(op, SpanOp::Merge(row) if row.name.is_empty() && row.end_ms == Some(3)) + )); + assert!(ops.iter().all(|op| !matches!(op, SpanOp::Merge(row) if row.metadata.as_ref().is_some_and(|metadata| metadata.get("total_turns").is_some())))); +} + +#[test] +fn pi_finalization_closes_missing_llm_and_tool_events() { + let registry = Registry::default_agents(); + let mut translator = registry.create("pi", "pi-session"); + let ctx = SessionCtx { + session_id: "pi-session".into(), + config: None, + }; + for envelope in [ + event("before_agent_start", 1, json!({"prompt":"work"})), + event("context", 2, json!({"messages":[]})), + event( + "tool_execution_start", + 3, + json!({"toolCallId":"call","toolName":"read","args":{"path":"x"}}), + ), + ] { + translator.handle(&envelope, &ctx).unwrap(); + } + let ops = translator.finalize(&ctx).unwrap(); + assert!(ops.iter().any(|op| matches!(op, SpanOp::Insert(row) if row.span_type == SpanType::Llm && row.error.is_some()))); + assert!(ops.iter().any(|op| matches!(op, SpanOp::Insert(row) if row.span_type == SpanType::Tool && row.error.is_some()))); +} diff --git a/bt-daemon/tests/pipeline.rs b/bt-daemon/tests/pipeline.rs index 44bbeae..6d439fb 100644 --- a/bt-daemon/tests/pipeline.rs +++ b/bt-daemon/tests/pipeline.rs @@ -6,8 +6,8 @@ use async_trait::async_trait; use bt_daemon::wire::{AuthSelection, BackendAuth, Envelope, SessionConfig, SessionRoute}; use bt_daemon::{ debug_serve_options, flush_managed_run, flush_session, forward_envelope, run_serve, run_status, - shutdown_daemon, AuthLease, AuthProvider, AuthResolveReason, HostInfo, Registry, ServeArgs, - ServeOptions, Sink, SinkFactory, SpanOp, StatusArgs, + shutdown_daemon, source_journal_path, AuthLease, AuthProvider, AuthResolveReason, HostInfo, + Registry, ServeArgs, ServeOptions, Sink, SinkFactory, SpanOp, StatusArgs, }; #[cfg(all(feature = "cli", unix))] use bt_daemon::{run_traced, RunArgs, RunHookCommand, RunSource}; @@ -447,8 +447,7 @@ async fn routed_sessions_resolve_multiple_profiles_without_journaling_credential for session in ["work-session", "personal-session"] { let journal = - std::fs::read_to_string(data_dir.join("journal").join(format!("{session}.ndjson"))) - .unwrap(); + std::fs::read_to_string(source_journal_path(&data_dir, "debug", session)).unwrap(); assert!(journal.contains("\"route\"")); assert!(!journal.contains("secret-")); assert!(!journal.contains("token_sha256_prefix")); @@ -719,7 +718,7 @@ async fn events_are_ordered_journaled_and_emitted() { assert_eq!(flushed.pending, 0); // Journal: three events, in order, with only the non-secret route. - let journal = data_dir.join("journal").join("sess-1.ndjson"); + let journal = source_journal_path(&data_dir, "debug", "sess-1"); let jtext = std::fs::read_to_string(&journal).unwrap(); let jlines: Vec<&str> = jtext.lines().filter(|l| !l.trim().is_empty()).collect(); assert_eq!( @@ -764,6 +763,20 @@ async fn events_are_ordered_journaled_and_emitted() { handle.abort(); } +#[tokio::test] +async fn unknown_sources_are_rejected_before_journaling() { + let (data_dir, socket, handle, _tmp) = start_daemon().await; + let mut env = envelope("unknown-session", "SessionStart", 1); + env.source = "mystery-agent".into(); + let error = forward_envelope(&env, &socket, &dummy_host(), false) + .await + .unwrap_err() + .to_string(); + assert!(error.contains("unsupported coding-agent source"), "{error}"); + assert!(!source_journal_path(&data_dir, "mystery-agent", "unknown-session").exists()); + handle.abort(); +} + #[tokio::test] async fn distinct_sessions_are_isolated() { let (data_dir, socket, handle, _tmp) = start_daemon().await; @@ -782,8 +795,8 @@ async fn distinct_sessions_are_isolated() { flush_session("a", &socket, 5000).await.unwrap(); flush_session("b", &socket, 5000).await.unwrap(); - let a = std::fs::read_to_string(data_dir.join("journal").join("a.ndjson")).unwrap(); - let b = std::fs::read_to_string(data_dir.join("journal").join("b.ndjson")).unwrap(); + let a = std::fs::read_to_string(source_journal_path(&data_dir, "debug", "a")).unwrap(); + let b = std::fs::read_to_string(source_journal_path(&data_dir, "debug", "b")).unwrap(); assert_eq!(a.lines().filter(|l| !l.trim().is_empty()).count(), 2); assert_eq!(b.lines().filter(|l| !l.trim().is_empty()).count(), 1); @@ -943,7 +956,8 @@ async fn restart_replays_journal_with_stable_span_ids_before_new_events() { .unwrap(); flush_session("resume", &socket, 5000).await.unwrap(); - let journal = std::fs::read_to_string(data_dir.join("journal/resume.ndjson")).unwrap(); + let journal = + std::fs::read_to_string(source_journal_path(&data_dir, "debug", "resume")).unwrap(); assert_eq!(journal.lines().count(), 2); let spans = std::fs::read_to_string(data_dir.join("spans/resume.ndjson")).unwrap(); let rows: Vec = spans @@ -1003,7 +1017,12 @@ async fn claude_boundary_journal_references_a_self_contained_transcript_mirror() .await .unwrap(); - let journal = std::fs::read_to_string(data_dir.join("journal/claude-journal.ndjson")).unwrap(); + let journal = std::fs::read_to_string(source_journal_path( + &data_dir, + "claude-code", + "claude-journal", + )) + .unwrap(); assert!(!journal.contains("sk-TOP-SECRET-abc123")); // The journal references the mirror rather than inlining the transcript, @@ -1104,7 +1123,7 @@ async fn claude_journal_does_not_grow_with_the_transcript_on_every_turn() { flush_session("grow", &socket, 5000).await.unwrap(); let transcript_len = std::fs::metadata(&transcript).unwrap().len(); - let journal_len = std::fs::metadata(data_dir.join("journal/grow.ndjson")) + let journal_len = std::fs::metadata(source_journal_path(&data_dir, "claude-code", "grow")) .unwrap() .len(); diff --git a/bt-daemon/tests/replay.rs b/bt-daemon/tests/replay.rs index 4ac4120..f402a48 100644 --- a/bt-daemon/tests/replay.rs +++ b/bt-daemon/tests/replay.rs @@ -262,10 +262,15 @@ async fn imports_native_codex_rollout_through_codex_translator() { let rows = rows(&output.join("codex-past.ndjson")); assert_eq!(inserted(&rows, "task"), 3, "session and two turns"); assert_eq!(inserted(&rows, "tool"), 1); - assert!(rows.iter().any(|row| row - .pointer("/Insert/metadata/source") - .and_then(Value::as_str) - == Some("import"))); + assert!(rows.iter().any(|row| { + row.pointer("/Insert/metadata/source") + .and_then(Value::as_str) + == Some("codex") + && row + .pointer("/Insert/metadata/session_source") + .and_then(Value::as_str) + == Some("import") + })); let turn_ids = rows .iter() .filter_map(|row| { diff --git a/bt-daemon/tests/translator_conformance.rs b/bt-daemon/tests/translator_conformance.rs new file mode 100644 index 0000000..1f19513 --- /dev/null +++ b/bt-daemon/tests/translator_conformance.rs @@ -0,0 +1,71 @@ +use bt_daemon::wire::Envelope; +use bt_daemon::{Registry, SessionCtx, SpanOp}; +use serde_json::json; + +fn envelope(source: &str, event: &str, payload: serde_json::Value) -> Envelope { + Envelope { + source: source.into(), + source_version: None, + plugin_version: None, + session_id: "shared-native-id".into(), + event: event.into(), + ts_ms: 1, + managed_run_id: None, + payload, + route: None, + config: None, + } +} + +#[test] +fn registry_rejects_unknown_sources_and_canonicalizes_aliases() { + let registry = Registry::default_agents(); + assert!(registry.create_checked("unknown-agent", "session").is_err()); + assert_eq!(registry.canonical_source("claude"), Some("claude-code")); +} + +#[test] +fn equal_native_session_ids_are_source_qualified_but_metadata_stays_native() { + let registry = Registry::default_agents(); + let ctx = SessionCtx { + session_id: "shared-native-id".into(), + config: None, + }; + let mut pi = registry.create("pi", "shared-native-id"); + let mut opencode = registry.create("opencode", "shared-native-id"); + let pi_root = pi + .handle(&envelope("pi", "session_start", json!({"event":{}})), &ctx) + .unwrap() + .into_iter() + .find_map(|op| match op { + SpanOp::Insert(row) => Some(row), + SpanOp::Merge(_) => None, + }) + .unwrap(); + let opencode_root = opencode + .handle( + &envelope( + "opencode", + "session.created", + json!({"properties":{"info":{"id":"shared-native-id"}}}), + ), + &ctx, + ) + .unwrap() + .into_iter() + .find_map(|op| match op { + SpanOp::Insert(row) => Some(row), + SpanOp::Merge(_) => None, + }) + .unwrap(); + + assert_ne!(pi_root.span_id, opencode_root.span_id); + assert_eq!( + pi_root.metadata.as_ref().unwrap()["session_id"], + "shared-native-id" + ); + assert_eq!( + opencode_root.metadata.as_ref().unwrap()["session_id"], + "shared-native-id" + ); +} diff --git a/scripts/build-common.sh b/scripts/build-common.sh deleted file mode 100755 index aeccb8c..0000000 --- a/scripts/build-common.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash -# Shared helpers for per-agent build.sh (ported from sentry-for-ai). -# copy_skills -> rsync skills + hydrate references.yml -# copy_skill_tree -# (placeholder) diff --git a/scripts/build-npm-plugin.sh b/scripts/build-npm-plugin.sh new file mode 100755 index 0000000..bca7554 --- /dev/null +++ b/scripts/build-npm-plugin.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Shared package assembly for the Pi and OpenCode npm distribution family. +set -euo pipefail + +AGENT="${1:?usage: build-npm-plugin.sh }" +TARGET_DIR="${2:?usage: build-npm-plugin.sh }" +case "$AGENT" in pi|opencode) ;; *) echo "unsupported npm plugin: $AGENT" >&2; exit 2;; esac + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CONTENT_DIR="$REPO_ROOT/src/plugins/$AGENT/content" + +rm -rf "$TARGET_DIR" +mkdir -p "$TARGET_DIR/src/runtime" +TARGET_DIR="$(cd "$TARGET_DIR" && pwd)" +tar \ + --exclude=node_modules \ + --exclude=dist \ + --exclude=.cache \ + --exclude=.vite \ + --exclude=src/runtime/daemon-client.ts \ + -cf - -C "$CONTENT_DIR" . | tar -xf - -C "$TARGET_DIR" +cp "$REPO_ROOT/src/runtime/js-daemon-client/src/index.ts" \ + "$TARGET_DIR/src/runtime/daemon-client.ts" + +(cd "$TARGET_DIR" && pnpm install --frozen-lockfile && pnpm run build:prepared) +rm -rf "$TARGET_DIR/node_modules" + +echo "Built $AGENT npm package in $TARGET_DIR" diff --git a/scripts/hydrate-references.py b/scripts/hydrate-references.py deleted file mode 100644 index ceb15e8..0000000 --- a/scripts/hydrate-references.py +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env -S uv run --script -"""Hydrate shared src/references/* into each skill that declares references.yml. -Ported from sentry-for-ai. (placeholder)""" diff --git a/scripts/publish-npm-plugin.sh b/scripts/publish-npm-plugin.sh new file mode 100755 index 0000000..488dc22 --- /dev/null +++ b/scripts/publish-npm-plugin.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Shared guarded local publish path for npm-delivered coding-agent plugins. +set -euo pipefail + +AGENT="${1:?usage: publish-npm-plugin.sh }" +case "$AGENT" in + pi) DISPLAY="Pi" ;; + opencode) DISPLAY="OpenCode" ;; + *) echo "unsupported npm plugin: $AGENT" >&2; exit 2 ;; +esac + +NPM_TAG="${NPM_TAG:-latest}" +case "$NPM_TAG" in latest|rc|next|beta) ;; *) echo "unsupported NPM_TAG: $NPM_TAG" >&2; exit 1;; esac + +if [[ "${DRY_RUN:-}" != "1" ]]; then + echo "Real $DISPLAY releases must use .github/workflows/release-$AGENT.yml" >&2 + echo "Set DRY_RUN=1 to validate the package locally." >&2 + exit 1 +fi + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +( + cd "$REPO_ROOT/src/plugins/$AGENT/content" + pnpm install --frozen-lockfile + pnpm run build + pnpm publish --dry-run --no-git-checks --tag "$NPM_TAG" +) diff --git a/scripts/render-hook-forwarders.py b/scripts/render-hook-forwarders.py new file mode 100755 index 0000000..d76cb2d --- /dev/null +++ b/scripts/render-hook-forwarders.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Render thin marketplace hook scripts from one canonical shell template.""" + +import argparse +import json +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parent.parent +TEMPLATE = REPO_ROOT / "src/runtime/hook-forwarder/forward.sh.tmpl" +SPECS = { + "claude": { + "source": "claude-code", + "plugin_name": "trace-claude-code", + "manifest": "plugins/trace-claude-code/.claude-plugin/plugin.json", + "forwarder": "plugins/trace-claude-code/hooks/forward.sh", + }, + "codex": { + "source": "codex", + "plugin_name": "trace-codex", + "manifest": "plugins/trace-codex/.codex-plugin/plugin.json", + "forwarder": "plugins/trace-codex/bin/codex-hook.sh", + }, +} + + +def render(agent: str, root: Path, check: bool) -> None: + spec = SPECS[agent] + manifest_path = root / spec["manifest"] + version = json.loads(manifest_path.read_text())["version"] + contents = TEMPLATE.read_text() + replacements = { + "@SOURCE@": spec["source"], + "@PLUGIN_NAME@": spec["plugin_name"], + "@PLUGIN_VERSION@": version, + } + for token, value in replacements.items(): + contents = contents.replace(token, value) + + destination = root / spec["forwarder"] + if check: + if not destination.exists() or destination.read_text() != contents: + raise SystemExit(f"generated hook forwarder differs: {destination}") + print(f"Verified {agent} hook forwarder") + return + + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(contents) + destination.chmod(0o755) + print(f"Rendered {agent} hook forwarder -> {destination}") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("agent", choices=[*SPECS, "all"]) + parser.add_argument("--check", action="store_true") + parser.add_argument( + "--target-root", + type=Path, + help="render/check one agent distribution root instead of checked-in content", + ) + args = parser.parse_args() + agents = list(SPECS) if args.agent == "all" else [args.agent] + if args.target_root is not None and len(agents) != 1: + parser.error("--target-root requires one agent") + for agent in agents: + root = ( + args.target_root.resolve() + if args.target_root is not None + else REPO_ROOT / "src/plugins" / agent / "content" + ) + render(agent, root, args.check) + + +if __name__ == "__main__": + main() diff --git a/scripts/set-plugin-version.py b/scripts/set-plugin-version.py index 6385474..f5f2ab3 100644 --- a/scripts/set-plugin-version.py +++ b/scripts/set-plugin-version.py @@ -13,6 +13,7 @@ import glob import os import re +import subprocess import sys # agent -> glob of its per-plugin manifests (relative to repo root) @@ -45,6 +46,13 @@ def main() -> None: f.write(new_text) print(f"set {os.path.relpath(path)} version -> {version}") + repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + subprocess.run( + [sys.executable, os.path.join(repo_root, "scripts/render-hook-forwarders.py"), agent], + check=True, + cwd=repo_root, + ) + if __name__ == "__main__": main() diff --git a/scripts/test-hook-forwarders.sh b/scripts/test-hook-forwarders.sh index 11791b6..81d8e3e 100644 --- a/scripts/test-hook-forwarders.sh +++ b/scripts/test-hook-forwarders.sh @@ -23,7 +23,8 @@ PAYLOAD='{"session_id":"shim-test","hook_event_name":"SessionStart","message":"u exercise() { local name="$1" local source="$2" - shift 2 + local plugin_version="$3" + shift 3 local args_file="$TEST_DIR/$name.args" local stdin_file="$TEST_DIR/$name.stdin" @@ -33,7 +34,7 @@ exercise() { BT_CAPTURE_STDIN="$stdin_file" \ "$@" - [[ "$(cat "$args_file")" == "trace hook --source $source" ]] + [[ "$(cat "$args_file")" == "trace hook --source $source --plugin-version $plugin_version" ]] [[ "$(cat "$stdin_file")" == "$PAYLOAD" ]] # The shim must swallow a daemon-client failure after forwarding the payload. @@ -45,9 +46,12 @@ exercise() { "$@" } -exercise claude claude-code \ +CLAUDE_PLUGIN_VERSION="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["version"])' "$DIST_DIR/claude/plugins/trace-claude-code/.claude-plugin/plugin.json")" +CODEX_PLUGIN_VERSION="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["version"])' "$DIST_DIR/codex/plugins/trace-codex/.codex-plugin/plugin.json")" + +exercise claude claude-code "$CLAUDE_PLUGIN_VERSION" \ bash "$DIST_DIR/claude/plugins/trace-claude-code/hooks/forward.sh" -exercise codex codex \ +exercise codex codex "$CODEX_PLUGIN_VERSION" \ bash "$DIST_DIR/codex/plugins/trace-codex/bin/codex-hook.sh" # Exercise first-use bootstrap without touching the developer's installation. @@ -71,7 +75,8 @@ chmod +x "$BOOTSTRAP_DIR/curl" "$TEST_DIR/installable-bt" bootstrap() { local name="$1" local source="$2" - shift 2 + local plugin_version="$3" + shift 3 local args_file="$TEST_DIR/$name.bootstrap.args" local stdin_file="$TEST_DIR/$name.bootstrap.stdin" local install_file="$TEST_DIR/$name.install.args" @@ -89,13 +94,13 @@ bootstrap() { "$@" [[ "$(cat "$install_file")" == "-fsSL https://bt.dev/cli/install.sh" ]] - [[ "$(cat "$args_file")" == "trace hook --source $source" ]] + [[ "$(cat "$args_file")" == "trace hook --source $source --plugin-version $plugin_version" ]] [[ "$(cat "$stdin_file")" == "$PAYLOAD" ]] } -bootstrap claude claude-code \ +bootstrap claude claude-code "$CLAUDE_PLUGIN_VERSION" \ bash "$DIST_DIR/claude/plugins/trace-claude-code/hooks/forward.sh" -bootstrap codex codex \ +bootstrap codex codex "$CODEX_PLUGIN_VERSION" \ bash "$DIST_DIR/codex/plugins/trace-codex/bin/codex-hook.sh" # No bt binary is also fail-open. Use an empty path so the test does not depend diff --git a/scripts/validate-hook-events.py b/scripts/validate-hook-events.py new file mode 100755 index 0000000..7d5b00c --- /dev/null +++ b/scripts/validate-hook-events.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Check shipped marketplace hooks against the canonical Rust AgentSpec events.""" + +import argparse +import json +import re +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parent.parent +CONSTANTS = {"claude": "CLAUDE_HOOK_EVENTS", "codex": "CODEX_HOOK_EVENTS"} + + +def canonical_events(agent: str) -> set[str]: + source = (REPO_ROOT / "bt-daemon/src/lib.rs").read_text() + name = CONSTANTS[agent] + match = re.search( + rf"const\s+{name}:\s*&\[&str\]\s*=\s*&\[(.*?)\];", source, re.DOTALL + ) + if not match: + raise SystemExit(f"could not find canonical {name} in bt-daemon/src/lib.rs") + return set(re.findall(r'"([^"]+)"', match.group(1))) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("agent", choices=CONSTANTS) + parser.add_argument("hooks_json", type=Path) + args = parser.parse_args() + + shipped = set(json.loads(args.hooks_json.read_text())["hooks"]) + canonical = canonical_events(args.agent) + if shipped != canonical: + missing = sorted(canonical - shipped) + extra = sorted(shipped - canonical) + raise SystemExit( + f"{args.agent} hook event drift: missing={missing or '[]'} extra={extra or '[]'}" + ) + print(f"Verified {args.agent} hook events ({len(shipped)})") + + +if __name__ == "__main__": + main() diff --git a/scripts/validate-npm-artifact.mjs b/scripts/validate-npm-artifact.mjs new file mode 100755 index 0000000..3c1b5e8 --- /dev/null +++ b/scripts/validate-npm-artifact.mjs @@ -0,0 +1,57 @@ +#!/usr/bin/env node +// Validate the exact npm artifact assembled under dist, without rebuilding it. +import { execFileSync } from "node:child_process" +import { readFileSync } from "node:fs" +import { resolve } from "node:path" + +const [agent, targetArg] = process.argv.slice(2) +if (!agent || !targetArg || !["pi", "opencode"].includes(agent)) { + throw new Error("usage: validate-npm-artifact.mjs ") +} +const target = resolve(targetArg) +const packOutput = execFileSync("pnpm", ["pack", "--dry-run", "--json"], { + cwd: target, + encoding: "utf8", +}) +const parsed = JSON.parse(packOutput) +const result = Array.isArray(parsed) ? parsed[0] : parsed +const files = new Set(result.files.map((file) => file.path)) +const required = ["dist/index.mjs", "dist/index.d.mts", "README.md", "LICENSE"] +if (agent === "opencode") required.push("dist/tracing.mjs", "dist/tracing.d.mts") +for (const file of required) { + if (!files.has(file)) throw new Error(`package omits ${file}`) +} +for (const file of files) { + if (file.startsWith("src/") || file.endsWith("daemon-client.ts")) { + throw new Error(`package exposes generated source: ${file}`) + } +} + +const manifest = JSON.parse(readFileSync(resolve(target, "package.json"), "utf8")) +if (agent === "opencode" && manifest.exports?.["./tracing"]?.import !== "./dist/tracing.mjs") { + throw new Error("OpenCode package does not export its trace-only managed entrypoint") +} +if (agent === "opencode") { + const pending = ["dist/tracing.mjs"] + const visited = new Set() + let tracingSource = "" + while (pending.length > 0) { + const file = pending.pop() + if (visited.has(file)) continue + visited.add(file) + const source = readFileSync(resolve(target, file), "utf8") + tracingSource += `\n${source}` + for (const match of source.matchAll(/from\s+["'](\.\/[^"']+\.mjs)["']/g)) { + pending.push(`dist/${match[1].slice(2)}`) + } + } + for (const marker of ["braintrust_list_projects", "braintrust_query_logs", "--prefer-profile"]) { + if (tracingSource.includes(marker)) { + throw new Error(`OpenCode trace-only entrypoint bundles data tools: ${marker}`) + } + } +} +for (const field of ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]) { + if (manifest[field]?.braintrust) throw new Error("package depends on the Braintrust JavaScript SDK") +} +console.log(`Validated ${agent} npm artifact (${files.size} files)`) diff --git a/src/plugins/claude/build.sh b/src/plugins/claude/build.sh index 9eaf32c..656a89f 100755 --- a/src/plugins/claude/build.sh +++ b/src/plugins/claude/build.sh @@ -18,8 +18,11 @@ set -euo pipefail TARGET_DIR="${1:?usage: build.sh }" SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" CONTENT_DIR="$SRC_DIR/content" +REPO_ROOT="$(cd "$SRC_DIR/../../.." && pwd)" +python3 "$REPO_ROOT/scripts/render-hook-forwarders.py" claude --check mkdir -p "$TARGET_DIR" rsync -a --delete --exclude '.git' "$CONTENT_DIR/" "$TARGET_DIR/" +python3 "$REPO_ROOT/scripts/render-hook-forwarders.py" claude --target-root "$TARGET_DIR" echo "Built claude dist into $TARGET_DIR (content from $CONTENT_DIR)." diff --git a/src/plugins/claude/content/README.md b/src/plugins/claude/content/README.md index 8d3dfed..3b1b7a3 100644 --- a/src/plugins/claude/content/README.md +++ b/src/plugins/claude/content/README.md @@ -45,6 +45,9 @@ owns authentication, trace construction, and delivery. bt trace enable claude --project my-coding-agent ``` +Use `bt trace disable claude` to remove the Braintrust tracing plugin and its +saved route without disturbing unrelated Claude plugins or settings. + Use `--profile` or `--org` when needed. Setup stores only non-secret routing settings under `~/.claude/braintrust.json`. Restart Claude Code after setup. diff --git a/src/plugins/claude/content/plugins/trace-claude-code/hooks/forward.sh b/src/plugins/claude/content/plugins/trace-claude-code/hooks/forward.sh old mode 100644 new mode 100755 index 72a660e..fe29897 --- a/src/plugins/claude/content/plugins/trace-claude-code/hooks/forward.sh +++ b/src/plugins/claude/content/plugins/trace-claude-code/hooks/forward.sh @@ -1,5 +1,5 @@ #!/bin/bash -# Thin, fail-open bridge from Claude Code hooks to the shared Braintrust daemon. +# Generated by scripts/render-hook-forwarders.py from the shared fail-open adapter. BT_INSTALL_URL="https://bt.dev/cli/install.sh" @@ -41,5 +41,5 @@ if [[ -z "$BT_BIN" ]]; then fi fi -"$BT_BIN" trace hook --source claude-code || true +"$BT_BIN" trace hook --source claude-code --plugin-version 2.0.1 || true exit 0 diff --git a/src/plugins/claude/validate.sh b/src/plugins/claude/validate.sh index 0c6274e..8ad1691 100755 --- a/src/plugins/claude/validate.sh +++ b/src/plugins/claude/validate.sh @@ -12,6 +12,7 @@ set -euo pipefail TARGET_DIR="${1:?usage: validate.sh }" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" fail() { echo "validate: $*" >&2; exit 1; } # jq if available (preferred), else python3 as a fallback JSON checker. @@ -41,7 +42,12 @@ for rel in "${required[@]}"; do [[ -f "$TARGET_DIR/$rel" ]] || fail "missing $rel" case "$rel" in *.json) check_json "$TARGET_DIR/$rel";; esac done +python3 "$REPO_ROOT/scripts/render-hook-forwarders.py" claude --target-root "$TARGET_DIR" --check \ + || fail "Claude forwarder differs from the canonical template" +python3 "$REPO_ROOT/scripts/validate-hook-events.py" claude \ + "$TARGET_DIR/plugins/trace-claude-code/hooks/hooks.json" \ + || fail "Claude shipped hook events differ from AgentSpec" python3 - "$TARGET_DIR/plugins/trace-claude-code/hooks/hooks.json" <<'PY' \ || fail "Claude hooks do not all use the blocking daemon forwarder" import json @@ -50,17 +56,6 @@ import sys with open(sys.argv[1]) as f: hooks = json.load(f)["hooks"] -expected_events = { - "ConfigChange", "CwdChanged", "Elicitation", "ElicitationResult", - "FileChanged", "InstructionsLoaded", "MessageDisplay", "Notification", - "PermissionDenied", "PermissionRequest", "PostCompact", "PostToolBatch", - "PostToolUse", "PostToolUseFailure", "PreCompact", "PreToolUse", - "SessionEnd", "SessionStart", "Setup", "Stop", "StopFailure", - "SubagentStart", "SubagentStop", "TaskCompleted", "TaskCreated", - "TeammateIdle", "UserPromptExpansion", "UserPromptSubmit", - "WorktreeCreate", "WorktreeRemove", -} -assert set(hooks) == expected_events for definitions in hooks.values(): for definition in definitions: for hook in definition["hooks"]: @@ -69,7 +64,7 @@ for definitions in hooks.values(): assert hook["async"] is False PY -grep -Fq 'trace hook --source claude-code' \ +grep -Fq 'trace hook --source claude-code --plugin-version' \ "$TARGET_DIR/plugins/trace-claude-code/hooks/forward.sh" \ || fail "Claude forwarder does not invoke bt trace hook with source claude-code" python3 - "$TARGET_DIR/plugins/trace-claude-code/hooks/forward.sh" <<'PY' \ diff --git a/src/plugins/codex/build.sh b/src/plugins/codex/build.sh index c85ca7b..b3448ee 100755 --- a/src/plugins/codex/build.sh +++ b/src/plugins/codex/build.sh @@ -17,8 +17,11 @@ set -euo pipefail TARGET_DIR="${1:?usage: build.sh }" SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" CONTENT_DIR="$SRC_DIR/content" +REPO_ROOT="$(cd "$SRC_DIR/../../.." && pwd)" +python3 "$REPO_ROOT/scripts/render-hook-forwarders.py" codex --check mkdir -p "$TARGET_DIR" rsync -a --delete --exclude '.git' --exclude 'node_modules' "$CONTENT_DIR/" "$TARGET_DIR/" +python3 "$REPO_ROOT/scripts/render-hook-forwarders.py" codex --target-root "$TARGET_DIR" echo "Built codex dist into $TARGET_DIR (content from $CONTENT_DIR)." diff --git a/src/plugins/codex/content/README.md b/src/plugins/codex/content/README.md index 9e18fc7..ddae159 100644 --- a/src/plugins/codex/content/README.md +++ b/src/plugins/codex/content/README.md @@ -25,6 +25,9 @@ The recommended tracing setup is: bt trace enable codex --project my-coding-agent ``` +Use `bt trace disable codex` to remove the Braintrust tracing plugin and its +saved route without disturbing unrelated Codex plugins or settings. + This installs the tracing plugin and stores only non-secret routing settings. The `bt` CLI owns authentication and forwards hook events through the shared daemon. Restart Codex after setup. diff --git a/src/plugins/codex/content/plugins/trace-codex/bin/codex-hook.sh b/src/plugins/codex/content/plugins/trace-codex/bin/codex-hook.sh old mode 100644 new mode 100755 index e07e15c..85e3909 --- a/src/plugins/codex/content/plugins/trace-codex/bin/codex-hook.sh +++ b/src/plugins/codex/content/plugins/trace-codex/bin/codex-hook.sh @@ -1,5 +1,5 @@ #!/bin/bash -# Thin, fail-open bridge from Codex hooks to the shared Braintrust daemon. +# Generated by scripts/render-hook-forwarders.py from the shared fail-open adapter. BT_INSTALL_URL="https://bt.dev/cli/install.sh" @@ -41,5 +41,5 @@ if [[ -z "$BT_BIN" ]]; then fi fi -"$BT_BIN" trace hook --source codex || true +"$BT_BIN" trace hook --source codex --plugin-version 1.0.1 || true exit 0 diff --git a/src/plugins/codex/content/plugins/trace-codex/hooks/hooks.json b/src/plugins/codex/content/plugins/trace-codex/hooks/hooks.json index a722d39..d573fbd 100644 --- a/src/plugins/codex/content/plugins/trace-codex/hooks/hooks.json +++ b/src/plugins/codex/content/plugins/trace-codex/hooks/hooks.json @@ -12,6 +12,18 @@ ] } ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "bash \"${PLUGIN_ROOT}/bin/codex-hook.sh\"", + "commandWindows": "bash \"${PLUGIN_ROOT}\\bin\\codex-hook.sh\"", + "statusMessage": "Braintrust tracing" + } + ] + } + ], "UserPromptSubmit": [ { "hooks": [ diff --git a/src/plugins/codex/validate.sh b/src/plugins/codex/validate.sh index d60309e..a6b6319 100755 --- a/src/plugins/codex/validate.sh +++ b/src/plugins/codex/validate.sh @@ -12,6 +12,7 @@ set -euo pipefail TARGET_DIR="${1:?usage: validate.sh }" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" fail() { echo "validate: $*" >&2; exit 1; } # jq if available (preferred), else python3 as a fallback JSON checker. @@ -41,7 +42,12 @@ for rel in "${required[@]}"; do [[ -f "$TARGET_DIR/$rel" ]] || fail "missing $rel" case "$rel" in *.json) check_json "$TARGET_DIR/$rel";; esac done +python3 "$REPO_ROOT/scripts/render-hook-forwarders.py" codex --target-root "$TARGET_DIR" --check \ + || fail "Codex forwarder differs from the canonical template" +python3 "$REPO_ROOT/scripts/validate-hook-events.py" codex \ + "$TARGET_DIR/plugins/trace-codex/hooks/hooks.json" \ + || fail "Codex shipped hook events differ from AgentSpec" python3 - "$TARGET_DIR/plugins/trace-codex/hooks/hooks.json" <<'PY' \ || fail "Codex hooks do not all use the daemon forwarders" import json @@ -50,12 +56,6 @@ import sys with open(sys.argv[1]) as f: hooks = json.load(f)["hooks"] -expected_events = { - "PermissionRequest", "PostCompact", "PostToolUse", "PreCompact", - "PreToolUse", "SessionStart", "Stop", "SubagentStart", "SubagentStop", - "UserPromptSubmit", -} -assert set(hooks) == expected_events for definitions in hooks.values(): for definition in definitions: for hook in definition["hooks"]: @@ -64,7 +64,7 @@ for definitions in hooks.values(): assert hook["commandWindows"] == 'bash "${PLUGIN_ROOT}\\bin\\codex-hook.sh"' PY -grep -Fq 'trace hook --source codex' \ +grep -Fq 'trace hook --source codex --plugin-version' \ "$TARGET_DIR/plugins/trace-codex/bin/codex-hook.sh" \ || fail "Codex Unix forwarder does not invoke bt trace hook with source codex" python3 - "$TARGET_DIR/plugins/trace-codex/bin/codex-hook.sh" <<'PY' \ diff --git a/src/plugins/opencode/build.sh b/src/plugins/opencode/build.sh index fbfe1ce..7b051a9 100755 --- a/src/plugins/opencode/build.sh +++ b/src/plugins/opencode/build.sh @@ -1,24 +1,4 @@ #!/usr/bin/env bash -set -euo pipefail - -TARGET_DIR="${1:?usage: build.sh }" PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$PLUGIN_DIR/../../.." && pwd)" - -rm -rf "$TARGET_DIR" -mkdir -p "$TARGET_DIR/src/runtime" -TARGET_DIR="$(cd "$TARGET_DIR" && pwd)" -tar \ - --exclude=node_modules \ - --exclude=dist \ - --exclude=.cache \ - --exclude=.vite \ - --exclude=src/runtime/daemon-client.ts \ - -cf - -C "$PLUGIN_DIR/content" . | tar -xf - -C "$TARGET_DIR" -cp "$REPO_ROOT/src/runtime/js-daemon-client/src/index.ts" \ - "$TARGET_DIR/src/runtime/daemon-client.ts" - -(cd "$TARGET_DIR" && pnpm install --frozen-lockfile && pnpm run build:prepared) -rm -rf "$TARGET_DIR/node_modules" - -echo "Built OpenCode npm package in $TARGET_DIR" +exec "$REPO_ROOT/scripts/build-npm-plugin.sh" opencode "${1:?usage: build.sh }" diff --git a/src/plugins/opencode/content/README.md b/src/plugins/opencode/content/README.md index cf7de22..acc06f0 100644 --- a/src/plugins/opencode/content/README.md +++ b/src/plugins/opencode/content/README.md @@ -21,7 +21,12 @@ opencode ``` For one invocation without changing OpenCode's global tracing configuration, -use `bt trace run --project opencode -- [OPENCODE_ARGS...]`. +use `bt trace run --project opencode -- [OPENCODE_ARGS...]`. Managed +runs load the package's trace-only entrypoint, so they do not add Braintrust +data-access tools to OpenCode. + +Use `bt trace disable opencode` to remove only the Braintrust-owned plugin +registration and settings while preserving unrelated OpenCode configuration. ## Configuration @@ -65,6 +70,9 @@ from the environment. Tracing routing and enablement (`trace_to_braintrust`, `profile`, `project`, `org_name`, `additional_metadata`) come only from `braintrust.json` and `bt trace run`. +Boolean settings accept `true`/`false`, `1`/`0`, `yes`/`no`, and `on`/`off` +case-insensitively. + ### Precedence Configuration is loaded with the following precedence (later overrides earlier): diff --git a/src/plugins/opencode/content/package.json b/src/plugins/opencode/content/package.json index 50709da..e6836b1 100644 --- a/src/plugins/opencode/content/package.json +++ b/src/plugins/opencode/content/package.json @@ -25,6 +25,16 @@ "type": "module", "main": "dist/index.mjs", "types": "dist/index.d.mts", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + }, + "./tracing": { + "types": "./dist/tracing.d.mts", + "import": "./dist/tracing.mjs" + } + }, "publishConfig": { "access": "public" }, @@ -36,7 +46,7 @@ "fmt": "vp fmt", "lint": "vp lint", "publish:dry-run": "pnpm run build && pnpm publish --dry-run --no-git-checks", - "smoke": "node -e \"import('./dist/index.mjs')\"", + "smoke": "node -e \"Promise.all([import('./dist/index.mjs'), import('./dist/tracing.mjs')])\"", "test": "pnpm run prepare:daemon-client && vitest run", "test:watch": "pnpm run prepare:daemon-client && vitest", "typecheck": "pnpm run check" diff --git a/src/plugins/opencode/content/src/config.test.ts b/src/plugins/opencode/content/src/config.test.ts index 90fcecb..f836fc8 100644 --- a/src/plugins/opencode/content/src/config.test.ts +++ b/src/plugins/opencode/content/src/config.test.ts @@ -8,10 +8,12 @@ describe("parseBooleanEnv", () => { expect(parseBooleanEnv("1")).toBe(true); }); - it("rejects other and missing values", () => { + it("accepts false spellings and rejects unknown or missing values", () => { expect(parseBooleanEnv(undefined)).toBe(false); expect(parseBooleanEnv("false")).toBe(false); - expect(parseBooleanEnv("yes")).toBe(false); + expect(parseBooleanEnv("yes")).toBe(true); + expect(parseBooleanEnv("off")).toBe(false); + expect(parseBooleanEnv("sometimes")).toBe(false); }); }); @@ -124,6 +126,21 @@ describe("loadConfig", () => { ).toEqual({ destination, auth: {}, flush_mode: "flush_on_turn_end" }); }); + it("preserves additional metadata nested in the canonical route", () => { + expect( + loadConfig({ + trace_to_braintrust: true, + route: { + destination: { type: "project_logs", project_name: "nested" }, + additional_metadata: { team: "platform" }, + }, + }), + ).toMatchObject({ + additionalMetadata: { team: "platform" }, + route: { additional_metadata: { team: "platform" } }, + }); + }); + it("does not fall back to persistent settings for a malformed managed run", () => { process.env.BT_TRACE_INVOCATION_SETTINGS = "{"; expect( diff --git a/src/plugins/opencode/content/src/config.ts b/src/plugins/opencode/content/src/config.ts index 2e641d8..8bb0360 100644 --- a/src/plugins/opencode/content/src/config.ts +++ b/src/plugins/opencode/content/src/config.ts @@ -1,4 +1,9 @@ -import { type DaemonSessionRoute, resolveDaemonTraceSettings } from "./runtime/daemon-client"; +import { + type DaemonSessionRoute, + jsonRecord, + parseOptionalBoolean, + resolveDaemonTraceSettings, +} from "./runtime/daemon-client"; /** OpenCode-specific behavior plus its independently selected tracing route. */ @@ -34,9 +39,7 @@ export interface PluginConfig { * All other values (including undefined, "false", "0", "no") are falsy. */ export function parseBooleanEnv(value: string | undefined): boolean { - if (!value) return false; - const normalized = value.toLowerCase(); - return normalized === "true" || normalized === "1"; + return parseOptionalBoolean(value) ?? false; } /** @@ -91,9 +94,10 @@ export function loadConfig(pluginConfig?: PluginConfig): BraintrustConfig { if (pluginConfig.debug !== undefined) { defaults.debug = pluginConfig.debug; } - if (pluginConfig.additional_metadata) { - defaults.additionalMetadata = pluginConfig.additional_metadata; - } + defaults.additionalMetadata = + jsonRecord(pluginConfig.additional_metadata) ?? + jsonRecord(pluginConfig.route?.additional_metadata) ?? + defaults.additionalMetadata; } const persistentRoute: DaemonSessionRoute = { @@ -125,13 +129,10 @@ export function loadConfig(pluginConfig?: PluginConfig): BraintrustConfig { ? routeDestination.project_name : defaults.projectName, tracingEnabled: traceSettings.trace_to_braintrust === true, - enableTools: process.env.BRAINTRUST_OPENCODE_ENABLE_TOOLS - ? parseBooleanEnv(process.env.BRAINTRUST_OPENCODE_ENABLE_TOOLS) - : defaults.enableTools, - debug: process.env.BRAINTRUST_DEBUG - ? parseBooleanEnv(process.env.BRAINTRUST_DEBUG) - : defaults.debug, - additionalMetadata: defaults.additionalMetadata, + enableTools: + parseOptionalBoolean(process.env.BRAINTRUST_OPENCODE_ENABLE_TOOLS) ?? defaults.enableTools, + debug: parseOptionalBoolean(process.env.BRAINTRUST_DEBUG) ?? defaults.debug, + additionalMetadata: jsonRecord(route.additional_metadata) ?? defaults.additionalMetadata, route, }; } diff --git a/src/plugins/opencode/content/src/index.test.ts b/src/plugins/opencode/content/src/index.test.ts index b2607a4..7b2558f 100644 --- a/src/plugins/opencode/content/src/index.test.ts +++ b/src/plugins/opencode/content/src/index.test.ts @@ -50,6 +50,8 @@ describe("BraintrustPlugin", () => { "TRACE_TO_BRAINTRUST", "BRAINTRUST_PROFILE", "BRAINTRUST_OPENCODE_ENABLE_TOOLS", + "BT_TRACE_INVOCATION_SETTINGS", + "BT_TRACE_MANAGED_RUN_ID", "HOME", "XDG_CONFIG_HOME", ]; @@ -119,4 +121,37 @@ describe("BraintrustPlugin", () => { expect(hooks["tool.execute.after"]).toBeDefined(); expect(hooks.tool).toBeUndefined(); }); + + it("provides a trace-only managed entrypoint without data tools", async () => { + process.env.BT_TRACE_MANAGED_RUN_ID = `test-${Date.now()}`; + process.env.BT_TRACE_INVOCATION_SETTINGS = JSON.stringify({ + trace_to_braintrust: true, + route: { + destination: { type: "project_logs", project_name: "managed" }, + }, + }); + const { default: tracingPlugin } = await import("./tracing"); + const hooks = await tracingPlugin(createInput(directory)); + + expect(hooks.event).toBeDefined(); + expect(hooks["chat.message"]).toBeDefined(); + expect(hooks.tool).toBeUndefined(); + }); + + it("registers only one tracing adapter when managed and installed copies load", async () => { + process.env.BT_TRACE_MANAGED_RUN_ID = `dedupe-${Date.now()}`; + process.env.BT_TRACE_INVOCATION_SETTINGS = JSON.stringify({ + trace_to_braintrust: true, + route: { destination: { type: "project_logs", project_name: "managed" } }, + }); + const [{ BraintrustPlugin }, { default: tracingPlugin }] = await Promise.all([ + import("./index"), + import("./tracing"), + ]); + + const installedHooks = await BraintrustPlugin(createInput(directory)); + const injectedHooks = await tracingPlugin(createInput(directory)); + expect(installedHooks.event).toBeDefined(); + expect(injectedHooks.event).toBeUndefined(); + }); }); diff --git a/src/plugins/opencode/content/src/index.ts b/src/plugins/opencode/content/src/index.ts index 099956a..5ecfed0 100644 --- a/src/plugins/opencode/content/src/index.ts +++ b/src/plugins/opencode/content/src/index.ts @@ -7,44 +7,16 @@ */ import type { Hooks, Plugin, PluginInput } from "@opencode-ai/plugin"; -import { loadConfig, type PluginConfig } from "./config"; +import { loadConfig } from "./config"; import { createBraintrustTools } from "./tools"; import { BtCliToolsClient } from "./tools/bt-cli"; -import { createDaemonTracingHooks } from "./tracing/daemon"; +import { addTracingHooks, readPluginConfig } from "./tracing/plugin"; + +export { BraintrustTracingPlugin } from "./tracing/plugin"; export const BraintrustPlugin: Plugin = async (input: PluginInput) => { const { client } = input; - - // Load plugin config from config files - // Precedence: global config -> project config (project overrides global) - let pluginConfig: PluginConfig | undefined; - try { - const fs = await import("node:fs"); - const path = await import("node:path"); - const os = await import("node:os"); - - // Load configs in order: global first, then project (so project overrides global) - const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"); - const configPaths = [ - path.join(configHome, "opencode", "braintrust.json"), // global - path.join(input.directory, ".opencode", "braintrust.json"), // project - ]; - - for (const configPath of configPaths) { - try { - if (fs.existsSync(configPath)) { - const content = fs.readFileSync(configPath, "utf-8"); - const parsed = JSON.parse(content) as PluginConfig; - // Merge: later config overrides earlier - pluginConfig = pluginConfig ? { ...pluginConfig, ...parsed } : parsed; - } - } catch { - // Continue to next path - } - } - } catch { - // Config loading failed, proceed with env vars only - } + const pluginConfig = await readPluginConfig(input); const config = loadConfig(pluginConfig); @@ -52,25 +24,7 @@ export const BraintrustPlugin: Plugin = async (input: PluginInput) => { const hooks: Hooks = {}; - // Add tracing hooks if enabled - if (config.tracingEnabled) { - const tracingHooks = createDaemonTracingHooks(input, config, (message, extra) => { - client.app - .log({ body: { service: "braintrust-trace", level: "warn", message, extra } }) - .catch(() => {}); - }); - Object.assign(hooks, tracingHooks); - - client.app - .log({ - body: { - service: "braintrust", - level: "info", - message: `Tracing hooks registered: ${Object.keys(tracingHooks).join(", ")}`, - }, - }) - .catch(() => {}); - } + addTracingHooks(input, config, hooks); if (toolsClient) { hooks.tool = createBraintrustTools(toolsClient); diff --git a/src/plugins/opencode/content/src/tracing.ts b/src/plugins/opencode/content/src/tracing.ts new file mode 100644 index 0000000..ed9c3e4 --- /dev/null +++ b/src/plugins/opencode/content/src/tracing.ts @@ -0,0 +1,5 @@ +/** + * Trace-only OpenCode plugin entrypoint for invocation-local managed runs. + * It intentionally omits the optional Braintrust data-access tools. + */ +export { BraintrustTracingPlugin as default } from "./tracing/plugin"; diff --git a/src/plugins/opencode/content/src/tracing/plugin.ts b/src/plugins/opencode/content/src/tracing/plugin.ts new file mode 100644 index 0000000..416d64c --- /dev/null +++ b/src/plugins/opencode/content/src/tracing/plugin.ts @@ -0,0 +1,77 @@ +import type { Hooks, Plugin, PluginInput } from "@opencode-ai/plugin"; +import { loadConfig, type PluginConfig } from "../config"; +import { claimManagedTracingInstance } from "../runtime/daemon-client"; +import { createDaemonTracingHooks } from "./daemon"; + +export async function readPluginConfig(input: PluginInput): Promise { + let pluginConfig: PluginConfig | undefined; + try { + const fs = await import("node:fs"); + const path = await import("node:path"); + const os = await import("node:os"); + const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"); + const configPaths = [ + path.join(configHome, "opencode", "braintrust.json"), + path.join(input.directory, ".opencode", "braintrust.json"), + ]; + + for (const configPath of configPaths) { + try { + if (fs.existsSync(configPath)) { + const parsed = JSON.parse(fs.readFileSync(configPath, "utf-8")) as PluginConfig; + pluginConfig = pluginConfig ? { ...pluginConfig, ...parsed } : parsed; + } + } catch { + // Malformed optional config is fail-open; continue to the next layer. + } + } + } catch { + // Config loading failed; environment settings can still enable tracing. + } + return pluginConfig; +} + +export function addTracingHooks( + input: PluginInput, + config: ReturnType, + hooks: Hooks, +): void { + if (!config.tracingEnabled) return; + if (!claimManagedTracingInstance("opencode")) { + input.client.app + .log({ + body: { + service: "braintrust-trace", + level: "info", + message: "Managed tracing is already registered by another plugin instance.", + }, + }) + .catch(() => {}); + return; + } + + const tracingHooks = createDaemonTracingHooks(input, config, (message, extra) => { + input.client.app + .log({ body: { service: "braintrust-trace", level: "warn", message, extra } }) + .catch(() => {}); + }); + Object.assign(hooks, tracingHooks); + + input.client.app + .log({ + body: { + service: "braintrust", + level: "info", + message: `Tracing hooks registered: ${Object.keys(tracingHooks).join(", ")}`, + }, + }) + .catch(() => {}); +} + +/** Trace-only entrypoint used by `bt trace run opencode`. */ +export const BraintrustTracingPlugin: Plugin = async (input: PluginInput) => { + const config = loadConfig(await readPluginConfig(input)); + const hooks: Hooks = {}; + addTracingHooks(input, config, hooks); + return hooks; +}; diff --git a/src/plugins/opencode/content/vite.config.ts b/src/plugins/opencode/content/vite.config.ts index 42cb9e7..36a2fc7 100644 --- a/src/plugins/opencode/content/vite.config.ts +++ b/src/plugins/opencode/content/vite.config.ts @@ -15,7 +15,7 @@ export default defineConfig({ include: ["src/**/*.test.ts"], }, pack: { - entry: ["src/index.ts"], + entry: ["src/index.ts", "src/tracing.ts"], dts: true, format: ["esm"], sourcemap: true, diff --git a/src/plugins/opencode/publish.sh b/src/plugins/opencode/publish.sh index 47bce57..3d3d346 100755 --- a/src/plugins/opencode/publish.sh +++ b/src/plugins/opencode/publish.sh @@ -1,19 +1,4 @@ #!/usr/bin/env bash -set -euo pipefail - PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -NPM_TAG="${NPM_TAG:-latest}" -case "$NPM_TAG" in latest|rc|next|beta) ;; *) echo "unsupported NPM_TAG: $NPM_TAG" >&2; exit 1;; esac - -if [[ "${DRY_RUN:-}" != "1" ]]; then - echo "Real OpenCode releases must use .github/workflows/release-opencode.yml" >&2 - echo "Set DRY_RUN=1 to validate the package locally." >&2 - exit 1 -fi - -( - cd "$PLUGIN_DIR/content" - pnpm install --frozen-lockfile - pnpm run build - pnpm publish --dry-run --no-git-checks --tag "$NPM_TAG" -) +REPO_ROOT="$(cd "$PLUGIN_DIR/../../.." && pwd)" +exec "$REPO_ROOT/scripts/publish-npm-plugin.sh" opencode diff --git a/src/plugins/opencode/validate.sh b/src/plugins/opencode/validate.sh index e03f543..b09bfe0 100755 --- a/src/plugins/opencode/validate.sh +++ b/src/plugins/opencode/validate.sh @@ -11,28 +11,17 @@ TARGET_DIR="$(cd "$TARGET_DIR" && pwd)" [[ -f "$TARGET_DIR/package.json" ]] || fail "missing package.json" [[ -f "$TARGET_DIR/dist/index.mjs" ]] || fail "missing production entrypoint" [[ -f "$TARGET_DIR/dist/index.d.mts" ]] || fail "missing production declarations" +[[ -f "$TARGET_DIR/dist/tracing.mjs" ]] || fail "missing trace-only managed entrypoint" +[[ -f "$TARGET_DIR/dist/tracing.d.mts" ]] || fail "missing trace-only declarations" (cd "$SOURCE_DIR" && pnpm install --frozen-lockfile) (cd "$SOURCE_DIR" && pnpm run check && pnpm test && pnpm run build && pnpm run smoke) node "$REPO_ROOT/scripts/prepare-js-daemon-client.mjs" opencode --check -pack_json="$(cd "$TARGET_DIR" && pnpm pack --dry-run --json)" -node -e ' - const parsed = JSON.parse(process.argv[1]) - const result = Array.isArray(parsed) ? parsed[0] : parsed - const files = new Set(result.files.map((file) => file.path)) - for (const required of ["dist/index.mjs", "dist/index.d.mts", "README.md", "LICENSE"]) { - if (!files.has(required)) throw new Error(`package omits ${required}`) - } - for (const file of files) { - if (file.startsWith("src/") || file.endsWith("daemon-client.ts")) { - throw new Error(`package exposes generated source: ${file}`) - } - } -' "$pack_json" -(cd "$SOURCE_DIR" && pnpm run publish:dry-run >/dev/null) +node "$REPO_ROOT/scripts/validate-npm-artifact.mjs" opencode "$TARGET_DIR" +(cd "$TARGET_DIR" && pnpm publish --dry-run --ignore-scripts --no-git-checks >/dev/null) -for removed in client.ts tracing.ts event-processor.ts replay.ts span-queue.ts span-sink.ts; do +for removed in client.ts event-processor.ts replay.ts span-queue.ts span-sink.ts; do [[ ! -e "$TARGET_DIR/src/$removed" ]] || fail "old JavaScript tracing runtime remains: src/$removed" done if grep -R -n -E "fetch\(|/v1/|/btql|apikey/login|from [\"']\.\./tools" "$TARGET_DIR/src/tracing"; then @@ -51,11 +40,4 @@ grep -q 'BtCliToolsClient' "$TARGET_DIR/src/tools/index.ts" \ || fail "data-access tools no longer delegate to bt" grep -q '"--prefer-profile"' "$TARGET_DIR/src/tools/bt-cli.ts" \ || fail "bt tool delegation does not prefer managed profiles" -node -e ' - const manifest = require(process.argv[1]) - for (const field of ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]) { - if (manifest[field]?.braintrust) process.exit(1) - } -' "$TARGET_DIR/package.json" || fail "OpenCode package still depends on the Braintrust JavaScript SDK" - echo "validate: OpenCode npm package OK ($TARGET_DIR)" diff --git a/src/plugins/pi/build.sh b/src/plugins/pi/build.sh index dbff9d2..748b9c4 100755 --- a/src/plugins/pi/build.sh +++ b/src/plugins/pi/build.sh @@ -1,24 +1,4 @@ #!/usr/bin/env bash -set -euo pipefail - -TARGET_DIR="${1:?usage: build.sh }" PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$PLUGIN_DIR/../../.." && pwd)" - -rm -rf "$TARGET_DIR" -mkdir -p "$TARGET_DIR/src/runtime" -TARGET_DIR="$(cd "$TARGET_DIR" && pwd)" -tar \ - --exclude=node_modules \ - --exclude=dist \ - --exclude=.cache \ - --exclude=.vite \ - --exclude=src/runtime/daemon-client.ts \ - -cf - -C "$PLUGIN_DIR/content" . | tar -xf - -C "$TARGET_DIR" -cp "$REPO_ROOT/src/runtime/js-daemon-client/src/index.ts" \ - "$TARGET_DIR/src/runtime/daemon-client.ts" - -(cd "$TARGET_DIR" && pnpm install --frozen-lockfile && pnpm run build:prepared) -rm -rf "$TARGET_DIR/node_modules" - -echo "Built Pi npm package in $TARGET_DIR" +exec "$REPO_ROOT/scripts/build-npm-plugin.sh" pi "${1:?usage: build.sh }" diff --git a/src/plugins/pi/content/README.md b/src/plugins/pi/content/README.md index bb9c9c0..42278e6 100644 --- a/src/plugins/pi/content/README.md +++ b/src/plugins/pi/content/README.md @@ -75,6 +75,9 @@ For one invocation without changing Pi's global tracing configuration, use matching `BRAINTRUST_*` environment variable; a plain `pi` session's extension does not. +Use `bt trace disable pi` to uninstall the Braintrust extension and remove its +saved route while preserving unrelated Pi configuration. + In interactive mode, the footer shows a `Braintrust` status indicator while tracing is active, and a widget below the editor shows a shortened clickable trace link when available. ## Configuration @@ -125,6 +128,9 @@ Example: still be set from the environment. Tracing routing and enablement come only from `braintrust.json` and `bt trace run`. +Boolean settings accept `true`/`false`, `1`/`0`, `yes`/`no`, and `on`/`off` +case-insensitively. + ## Notes - Project config overrides global config. diff --git a/src/plugins/pi/content/src/config.test.ts b/src/plugins/pi/content/src/config.test.ts index 0cf543d..1cde780 100644 --- a/src/plugins/pi/content/src/config.test.ts +++ b/src/plugins/pi/content/src/config.test.ts @@ -13,6 +13,7 @@ const ENVIRONMENT_KEYS = [ "BRAINTRUST_SHOW_TRACE_LINK", "TRACE_TO_BRAINTRUST", "BT_TRACE_INVOCATION_SETTINGS", + "BT_TRACE_MANAGED_RUN_ID", ] as const; const originalEnvironment = new Map(); @@ -187,6 +188,21 @@ describe("loadConfig", () => { expect(loadConfig(cwd).route).toMatchObject({ destination }); }); + it("preserves additional metadata nested in the canonical route", () => { + writeJson(join(home, ".pi", "agent", "braintrust.json"), { + trace_to_braintrust: true, + route: { + destination: { type: "project_logs", project_name: "nested" }, + additional_metadata: { team: "platform" }, + }, + }); + + expect(loadConfig(cwd)).toMatchObject({ + additionalMetadata: { team: "platform" }, + route: { additional_metadata: { team: "platform" } }, + }); + }); + it("does not fall back to persistent settings for a malformed managed run", () => { writeJson(join(home, ".pi", "agent", "braintrust.json"), { trace_to_braintrust: true, diff --git a/src/plugins/pi/content/src/config.ts b/src/plugins/pi/content/src/config.ts index a1e9cd4..f868d80 100644 --- a/src/plugins/pi/content/src/config.ts +++ b/src/plugins/pi/content/src/config.ts @@ -2,7 +2,12 @@ import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import * as piCodingAgent from "@earendil-works/pi-coding-agent"; -import { type DaemonSessionRoute, resolveDaemonTraceSettings } from "./runtime/daemon-client.ts"; +import { + type DaemonSessionRoute, + jsonRecord, + parseOptionalBoolean, + resolveDaemonTraceSettings, +} from "./runtime/daemon-client.ts"; export interface PiConfig { enabled: boolean; @@ -22,39 +27,14 @@ const PROJECT_CONFIG_DIR_NAME = ? (piCodingAgent as { CONFIG_DIR_NAME: string }).CONFIG_DIR_NAME : ".pi"; -function record(value: unknown): ConfigRecord | undefined { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? (value as ConfigRecord) - : undefined; -} - function nonEmptyString(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value : undefined; } -function boolean(value: unknown): boolean | undefined { - if (typeof value === "boolean") return value; - if (typeof value !== "string" && typeof value !== "number") return undefined; - switch (String(value).trim().toLowerCase()) { - case "1": - case "true": - case "yes": - case "on": - return true; - case "0": - case "false": - case "no": - case "off": - return false; - default: - return undefined; - } -} - function readConfig(path: string): ConfigRecord | undefined { if (!existsSync(path)) return undefined; try { - return record(JSON.parse(readFileSync(path, "utf8"))); + return jsonRecord(JSON.parse(readFileSync(path, "utf8"))); } catch { return undefined; } @@ -62,9 +42,9 @@ function readConfig(path: string): ConfigRecord | undefined { function applyConfig(config: PiConfig, source: ConfigRecord | undefined): void { if (!source) return; - const route = record(source.route) as DaemonSessionRoute | undefined; + const route = jsonRecord(source.route) as DaemonSessionRoute | undefined; if (route?.destination !== undefined) config.route = route; - const destination = record(route?.destination); + const destination = jsonRecord(route?.destination); config.profile = nonEmptyString(route?.auth?.profile) ?? config.profile; config.orgName = nonEmptyString(route?.auth?.org_name) ?? config.orgName; if (destination?.type === "project_logs") { @@ -73,15 +53,19 @@ function applyConfig(config: PiConfig, source: ConfigRecord | undefined): void { config.profile = nonEmptyString(source.profile) ?? config.profile; config.orgName = nonEmptyString(source.org_name) ?? config.orgName; config.projectName = nonEmptyString(source.project) ?? config.projectName; - config.enabled = boolean(source.trace_to_braintrust) ?? config.enabled; - config.additionalMetadata = record(source.additional_metadata) ?? config.additionalMetadata; - config.showUi = boolean(source.show_ui) ?? config.showUi; - config.showTraceLink = boolean(source.show_trace_link) ?? config.showTraceLink; + config.enabled = parseOptionalBoolean(source.trace_to_braintrust) ?? config.enabled; + config.additionalMetadata = + jsonRecord(source.additional_metadata) ?? + jsonRecord(route?.additional_metadata) ?? + config.additionalMetadata; + config.showUi = parseOptionalBoolean(source.show_ui) ?? config.showUi; + config.showTraceLink = parseOptionalBoolean(source.show_trace_link) ?? config.showTraceLink; const profile = nonEmptyString(source.profile); const orgName = nonEmptyString(source.org_name); const projectName = nonEmptyString(source.project); - const additionalMetadata = record(source.additional_metadata); + const additionalMetadata = + jsonRecord(source.additional_metadata) ?? jsonRecord(route?.additional_metadata); if (profile || orgName) { config.route.auth = { ...config.route.auth, @@ -110,8 +94,9 @@ export function loadConfig(cwd = process.cwd()): PiConfig { applyConfig(config, readConfig(join(homedir(), ".pi", "agent", "braintrust.json"))); applyConfig(config, readConfig(join(cwd, PROJECT_CONFIG_DIR_NAME, "braintrust.json"))); - config.showUi = boolean(process.env.BRAINTRUST_SHOW_UI) ?? config.showUi; - config.showTraceLink = boolean(process.env.BRAINTRUST_SHOW_TRACE_LINK) ?? config.showTraceLink; + config.showUi = parseOptionalBoolean(process.env.BRAINTRUST_SHOW_UI) ?? config.showUi; + config.showTraceLink = + parseOptionalBoolean(process.env.BRAINTRUST_SHOW_TRACE_LINK) ?? config.showTraceLink; const traceSettings = resolveDaemonTraceSettings({ trace_to_braintrust: config.enabled, diff --git a/src/plugins/pi/content/src/daemon-adapter.test.ts b/src/plugins/pi/content/src/daemon-adapter.test.ts index 830fa80..d438002 100644 --- a/src/plugins/pi/content/src/daemon-adapter.test.ts +++ b/src/plugins/pi/content/src/daemon-adapter.test.ts @@ -4,9 +4,11 @@ const mockState = vi.hoisted(() => ({ logs: [] as Array>, flushes: [] as string[], closed: 0, + claim: true, })); vi.mock("./runtime/daemon-client.ts", () => ({ + claimManagedTracingInstance: () => mockState.claim, DaemonClient: class { async log(envelope: Record): Promise { mockState.logs.push(envelope); @@ -60,6 +62,7 @@ describe("Pi daemon adapter", () => { mockState.logs.length = 0; mockState.flushes.length = 0; mockState.closed = 0; + mockState.claim = true; }); it("forwards native events and keeps the trace-link UI", async () => { @@ -143,4 +146,16 @@ describe("Pi daemon adapter", () => { expect(statuses.length).toBeGreaterThan(0); expect(mockState.closed).toBe(1); }); + + it("does not register a duplicate managed adapter instance", async () => { + mockState.claim = false; + const handlers = new Map Promise>(); + const pi = { + on: (name: string, handler: (...args: unknown[]) => Promise) => + handlers.set(name, handler), + }; + const { default: extension } = await import("./index.ts"); + extension(pi as never); + expect(handlers.size).toBe(0); + }); }); diff --git a/src/plugins/pi/content/src/index.ts b/src/plugins/pi/content/src/index.ts index 6476acd..ffab7a2 100644 --- a/src/plugins/pi/content/src/index.ts +++ b/src/plugins/pi/content/src/index.ts @@ -6,7 +6,7 @@ import { type ExtensionContext, } from "@earendil-works/pi-coding-agent"; import { loadConfig } from "./config.ts"; -import { DaemonClient } from "./runtime/daemon-client.ts"; +import { claimManagedTracingInstance, DaemonClient } from "./runtime/daemon-client.ts"; import { EXTENSION_VERSION } from "./version.ts"; const STATUS_KEY = "braintrust-tracing"; @@ -47,6 +47,7 @@ function sessionDescriptor(ctx: ExtensionContext): { export default function braintrustPiExtension(pi: ExtensionAPI): void { const config = loadConfig(process.cwd()); if (!config.enabled) return; + if (!claimManagedTracingInstance("pi")) return; let sessionId: string | undefined; let lastContext: ExtensionContext | undefined; diff --git a/src/plugins/pi/publish.sh b/src/plugins/pi/publish.sh index 6b021e1..0adb45a 100755 --- a/src/plugins/pi/publish.sh +++ b/src/plugins/pi/publish.sh @@ -1,19 +1,4 @@ #!/usr/bin/env bash -set -euo pipefail - PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -NPM_TAG="${NPM_TAG:-latest}" -case "$NPM_TAG" in latest|rc|next|beta) ;; *) echo "unsupported NPM_TAG: $NPM_TAG" >&2; exit 1;; esac - -if [[ "${DRY_RUN:-}" != "1" ]]; then - echo "Real Pi releases must use .github/workflows/release-pi.yml" >&2 - echo "Set DRY_RUN=1 to validate the package locally." >&2 - exit 1 -fi - -( - cd "$PLUGIN_DIR/content" - pnpm install --frozen-lockfile - pnpm run build - pnpm publish --dry-run --no-git-checks --tag "$NPM_TAG" -) +REPO_ROOT="$(cd "$PLUGIN_DIR/../../.." && pwd)" +exec "$REPO_ROOT/scripts/publish-npm-plugin.sh" pi diff --git a/src/plugins/pi/validate.sh b/src/plugins/pi/validate.sh index 0c667c1..d6ec3a0 100755 --- a/src/plugins/pi/validate.sh +++ b/src/plugins/pi/validate.sh @@ -16,28 +16,8 @@ TARGET_DIR="$(cd "$TARGET_DIR" && pwd)" (cd "$SOURCE_DIR" && pnpm run check && pnpm test && pnpm run build && pnpm run smoke) node "$REPO_ROOT/scripts/prepare-js-daemon-client.mjs" pi --check -pack_json="$(cd "$TARGET_DIR" && pnpm pack --dry-run --json)" -node -e ' - const parsed = JSON.parse(process.argv[1]) - const result = Array.isArray(parsed) ? parsed[0] : parsed - const files = new Set(result.files.map((file) => file.path)) - for (const required of ["dist/index.mjs", "dist/index.d.mts", "README.md", "LICENSE"]) { - if (!files.has(required)) throw new Error(`package omits ${required}`) - } - for (const file of files) { - if (file.startsWith("src/") || file.endsWith("daemon-client.ts")) { - throw new Error(`package exposes generated source: ${file}`) - } - } -' "$pack_json" -(cd "$SOURCE_DIR" && pnpm run publish:dry-run >/dev/null) - -node -e ' - const manifest = require(process.argv[1]) - for (const field of ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]) { - if (manifest[field]?.braintrust) process.exit(1) - } -' "$TARGET_DIR/package.json" || fail "Braintrust SDK remains in Pi package dependencies" +node "$REPO_ROOT/scripts/validate-npm-artifact.mjs" pi "$TARGET_DIR" +(cd "$TARGET_DIR" && pnpm publish --dry-run --ignore-scripts --no-git-checks >/dev/null) for removed in client.ts legacy-processor.ts state.ts types.ts utils.ts; do [[ ! -e "$TARGET_DIR/src/$removed" ]] || fail "legacy tracing source remains: src/$removed" done diff --git a/src/runtime/hook-forwarder/forward.sh.tmpl b/src/runtime/hook-forwarder/forward.sh.tmpl new file mode 100644 index 0000000..2b3a994 --- /dev/null +++ b/src/runtime/hook-forwarder/forward.sh.tmpl @@ -0,0 +1,45 @@ +#!/bin/bash +# Generated by scripts/render-hook-forwarders.py from the shared fail-open adapter. + +BT_INSTALL_URL="https://bt.dev/cli/install.sh" + +resolve_bt() { + if command -v bt >/dev/null 2>&1; then + command -v bt + return 0 + fi + + local candidate + for candidate in \ + "${XDG_BIN_HOME:-${HOME:-}/.local/bin}/bt" \ + "${CARGO_HOME:-${HOME:-}/.cargo}/bin/bt"; do + if [[ -n "$candidate" && -x "$candidate" ]]; then + printf '%s\n' "$candidate" + return 0 + fi + done + return 1 +} + +BT_BIN="$(resolve_bt || true)" +if [[ -z "$BT_BIN" ]]; then + if ! command -v curl >/dev/null 2>&1; then + printf '@PLUGIN_NAME@: curl is required to install bt; tracing skipped\n' >&2 + exit 0 + fi + + printf '@PLUGIN_NAME@: bt CLI not found; installing it now\n' >&2 + if ! (set -o pipefail; curl -fsSL "$BT_INSTALL_URL" | bash) >&2; then + printf '@PLUGIN_NAME@: bt installation failed; tracing skipped\n' >&2 + exit 0 + fi + + BT_BIN="$(resolve_bt || true)" + if [[ -z "$BT_BIN" ]]; then + printf '@PLUGIN_NAME@: bt was installed but is not executable; tracing skipped\n' >&2 + exit 0 + fi +fi + +"$BT_BIN" trace hook --source @SOURCE@ --plugin-version @PLUGIN_VERSION@ || true +exit 0 diff --git a/src/runtime/js-daemon-client/src/index.ts b/src/runtime/js-daemon-client/src/index.ts index 1e6c6b9..442cd7c 100644 --- a/src/runtime/js-daemon-client/src/index.ts +++ b/src/runtime/js-daemon-client/src/index.ts @@ -21,6 +21,72 @@ export interface DaemonTraceSettings { route?: DaemonSessionRoute } +export type JsonRecord = Record + +/** Return an object-shaped JSON value without accepting arrays or null. */ +export function jsonRecord(value: unknown): JsonRecord | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as JsonRecord) + : undefined +} + +/** Parse the boolean spellings accepted by every JavaScript adapter. */ +export function parseOptionalBoolean(value: unknown): boolean | undefined { + if (typeof value === "boolean") return value + if (typeof value !== "string" && typeof value !== "number") return undefined + switch (String(value).trim().toLowerCase()) { + case "1": + case "true": + case "yes": + case "on": + return true + case "0": + case "false": + case "no": + case "off": + return false + default: + return undefined + } +} + +/** Parse optional object metadata while treating malformed values as absent. */ +export function parseJsonRecord(value: string | undefined): JsonRecord | undefined { + if (!value) return undefined + try { + return jsonRecord(JSON.parse(value)) + } catch { + return undefined + } +} + +const MANAGED_INSTANCE_CLAIMS = Symbol.for("braintrust.coding-agent.managed-instance-claims") + +/** + * Claim capture for one source in a managed process. + * + * A managed agent may load both its persistently installed adapter and the + * invocation-injected adapter. They run in the same JavaScript process, so a + * process-global claim keeps exactly one live forwarder. Ordinary (unmanaged) + * plugin instances are deliberately unaffected. + */ +export function claimManagedTracingInstance( + source: string, + env: NodeJS.ProcessEnv = process.env, + target: typeof globalThis = globalThis, +): boolean { + const managedRunId = env.BT_TRACE_MANAGED_RUN_ID + if (!managedRunId) return true + const globals = target as typeof globalThis & { + [MANAGED_INSTANCE_CLAIMS]?: Set + } + const claims = (globals[MANAGED_INSTANCE_CLAIMS] ??= new Set()) + const key = `${source}:${managedRunId}` + if (claims.has(key)) return false + claims.add(key) + return true +} + /** * Apply the invocation-only selection created by `bt trace run` without * mutating or falling back to an agent's persistent configuration. diff --git a/src/runtime/js-daemon-client/tests/client.test.ts b/src/runtime/js-daemon-client/tests/client.test.ts index 53eff79..6f47c1c 100644 --- a/src/runtime/js-daemon-client/tests/client.test.ts +++ b/src/runtime/js-daemon-client/tests/client.test.ts @@ -6,11 +6,52 @@ import { tmpdir } from "node:os" import { join } from "node:path" import { describe, test } from "node:test" import { + claimManagedTracingInstance, DaemonClient, daemonSocketPath, + jsonRecord, + parseJsonRecord, + parseOptionalBoolean, resolveDaemonTraceSettings, } from "../src/index.ts" +describe("shared adapter configuration", () => { + test("normalizes the same boolean spellings for every adapter", () => { + for (const value of [true, 1, "true", "TRUE", "yes", "on"]) { + assert.equal(parseOptionalBoolean(value), true) + } + for (const value of [false, 0, "false", "FALSE", "no", "off"]) { + assert.equal(parseOptionalBoolean(value), false) + } + assert.equal(parseOptionalBoolean("sometimes"), undefined) + }) + + test("accepts object metadata but rejects arrays and malformed JSON", () => { + assert.deepEqual(jsonRecord({ team: "platform" }), { team: "platform" }) + assert.equal(jsonRecord([]), undefined) + assert.deepEqual(parseJsonRecord('{"ci":true}'), { ci: true }) + assert.equal(parseJsonRecord("{"), undefined) + }) + + test("deduplicates only managed instances for the same source and run", () => { + const target = {} as typeof globalThis + assert.equal(claimManagedTracingInstance("pi", {}, target), true) + assert.equal(claimManagedTracingInstance("pi", {}, target), true) + const env = { BT_TRACE_MANAGED_RUN_ID: "run-1" } + assert.equal(claimManagedTracingInstance("pi", env, target), true) + assert.equal(claimManagedTracingInstance("pi", env, target), false) + assert.equal( + claimManagedTracingInstance("opencode", env, target), + true, + "different sources retain independent claims", + ) + assert.equal( + claimManagedTracingInstance("pi", { BT_TRACE_MANAGED_RUN_ID: "run-2" }, target), + true, + ) + }) +}) + describe("daemonSocketPath", () => { test("prefers the explicit environment override", () => { assert.equal(daemonSocketPath({ BT_DAEMON_SOCKET: "/tmp/custom.sock" }), "/tmp/custom.sock")